PageSourceSearch

https://www.loanquantum.com/js/QuantumCopilot/copilot-voice-typing.js

js loanquantum.com collected 2026-10-05 18:58:55 UTC 32,621 bytes, 994 lines download raw bytes

1class EnhancedVoiceTyping {
2    constructor(textareaId, checkboxId, options = {}) {
3        // Element references
4        this.textarea = document.getElementById(textareaId);
5        this.checkbox = document.getElementById(checkboxId);
6
7        // Configuration with defaults
8        this.config = {
9            lang: options.lang || 'en-US',
10            continuous: options.continuous !== false,
11            interimResults: options.interimResults !== false,
12            maxAlternatives: options.maxAlternatives || 3,
13            autoRestart: options.autoRestart !== false,
14            autoCapitalize: options.autoCapitalize !== false,
15            autoPunctuation: options.autoPunctuation !== false,
16            commands: options.commands !== false,
17            visualFeedback: options.visualFeedback !== false,
18            autoSubmit: options.autoSubmit !== false,
19            confirmSubmit: options.confirmSubmit || false,
20            removeCommandWords: options.removeCommandWords !== false,
21            ...options
22        };
23
24        // State management
25        this.state = {
26            isListening: false,
27            isPaused: false,
28            finalText: '',
29            interimText: '',
30            lastSpeechTime: Date.now(),
31            silenceTimer: null,
32            errorCount: 0,
33            history: [],
34            cursorPosition: 0
35        };
36
37        // Submission flag to prevent duplicates
38        this.isSubmitting = false;
39
40        // Speech recognition setup
41        this.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
42        this.recognition = null;
43
44        // Voice commands
45        this.voiceCommands = {
46            'new line': () => this.insertText('\n'),
47            'new paragraph': () => this.insertText('\n\t'),
48            'comma': () => this.insertText(', '),
49            'period': () => this.insertText('. '),
50            'question mark': () => this.insertText('? '),
51            'exclamation': () => this.insertText('! '),
52            'clear all': () => this.clearText(),
53            'undo': () => this.undo(),
54            'stop listening': () => this.stop(),
55            'pause': () => this.pause(),
56            'resume': () => this.resume(),
57            'enter': () => this.submitForm(),
58            'submit': () => this.submitForm(),
59            'send': () => this.submitForm(),
60            'submit form': () => this.submitForm(),
61            'send message': () => this.submitForm()
62        };
63
64        this.init();
65    }
66
67    init() {
68        if (!this.SpeechRecognition) {
69            this.showError('Speech Recognition is not supported in your browser. Please use Chrome, Edge, or Safari.');
70            this.checkbox.disabled = true;
71            return;
72        }
73
74        this.setupRecognition();
75        this.setupEventListeners();
76        this.setupVisualFeedback();
77        this.loadSavedState();
78    }
79
80    setupRecognition() {
81        this.recognition = new this.SpeechRecognition();
82        this.recognition.continuous = this.config.continuous;
83        this.recognition.interimResults = this.config.interimResults;
84        this.recognition.lang = this.config.lang;
85        this.recognition.maxAlternatives = this.config.maxAlternatives;
86
87        // Event handlers
88        this.recognition.onstart = () => this.handleStart();
89        this.recognition.onresult = (event) => this.handleResult(event);
90        this.recognition.onerror = (event) => this.handleError(event);
91        this.recognition.onend = () => this.handleEnd();
92        this.recognition.onspeechend = () => this.handleSpeechEnd();
93        this.recognition.onnomatch = () => this.handleNoMatch();
94    }
95
96    setupEventListeners() {
97        // Checkbox toggle
98        this.checkbox.addEventListener('change', () => {
99            if (this.checkbox.checked) {
100                this.start();
101            } else {
102                this.stop();
103            }
104        });
105
106        // Keyboard shortcuts
107        document.addEventListener('keydown', (e) => {
108            // Alt + V to toggle voice typing
109            if (e.altKey && e.key === 'v') {
110                e.preventDefault();
111                this.checkbox.checked = !this.checkbox.checked;
112                this.checkbox.dispatchEvent(new Event('change'));
113            }
114
115            // Escape to stop
116            if (e.key === 'Escape' && this.state.isListening) {
117                this.stop();
118            }
119        });
120
121        // Auto-save on input
122        this.textarea.addEventListener('input', () => {
123            this.saveState();
124        });
125
126        // Track cursor position
127        this.textarea.addEventListener('click', () => {
128            this.state.cursorPosition = this.textarea.selectionStart;
129        });
130
131        this.textarea.addEventListener('keyup', () => {
132            this.state.cursorPosition = this.textarea.selectionStart;
133        });
134
135        // Handle page visibility changes
136        document.addEventListener('visibilitychange', () => {
137            if (document.hidden && this.state.isListening) {
138                this.pause();
139            }
140        });
141
142        // Handle before unload
143        window.addEventListener('beforeunload', () => {
144            if (this.state.isListening) {
145                this.forceStop();
146            }
147        });
148    }
149
150    setupVisualFeedback() {
151        if (!this.config.visualFeedback) return;
152
153        // Create visual indicator
154        const indicator = document.createElement('div');
155        indicator.className = 'voice-indicator';
156        indicator.innerHTML = `
157            <div class="voice-status">
158                <span class="status-icon"></span>
159                <span class="status-text">Ready</span>
160            </div>
161            <div class="voice-meter">
162                <div class="voice-level"></div>
163            </div>
164        `;
165
166        // Insert after textarea
167        this.textarea.parentNode.insertBefore(indicator, this.textarea.nextSibling);
168        this.indicator = indicator;
169
170        // Add styles
171        this.addStyles();
172    }
173
174    addStyles() {
175        if (document.getElementById('voice-typing-styles')) return;
176
177        const styles = document.createElement('style');
178        styles.id = 'voice-typing-styles';
179        styles.textContent = `
180            .voice-indicator {
181                display: none;
182                padding: 10px;
183                background: #f8f9fa;
184                border-radius: 8px;
185                margin-top: 10px;
186                animation: slideIn 0.3s ease-out;
187            }
188            
189            .voice-indicator.active {
190                display: block;
191            }
192            
193            .voice-status {
194                display: flex;
195                align-items: center;
196                gap: 8px;
197                margin-bottom: 8px;
198            }
199            
200            .status-icon {
201                width: 12px;
202                height: 12px;
203                border-radius: 50%;
204                background: #6c757d;
205                animation: pulse 2s infinite;
206            }
207            
208            .status-icon.listening {
209                background: #28a745;
210                animation: pulse 1s infinite;
211            }
212            
213            .status-icon.processing {
214                background: #ffc107;
215                animation: spin 1s linear infinite;
216            }
217            
218            .status-icon.error {
219                background: #dc3545;
220                animation: none;
221            }
222            
223            .voice-meter {
224                height: 4px;
225                background: #e9ecef;
226                border-radius: 2px;
227                overflow: hidden;
228            }
229            
230            .voice-level {
231                height: 100%;
232                background: linear-gradient(90deg, #28a745, #20c997);
233                width: 0%;
234                transition: width 0.1s ease-out;
235            }
236            
237            @keyframes pulse {
238                0%, 100% { opacity: 1; }
239                50% { opacity: 0.5; }
240            }
241            
242            @keyframes spin {
243                0% { transform: rotate(0deg); }
244                100% { transform: rotate(360deg); }
245            }
246            
247            @keyframes slideIn {
248                from {
249                    opacity: 0;
250                    transform: translateY(-10px);
251                }
252                to {
253                    opacity: 1;
254                    transform: translateY(0);
255                }
256            }
257            
258            .voice-tooltip {
259                position: absolute;
260                background: #333;
261                color: white;
262                padding: 5px 10px;
263                border-radius: 4px;
264                font-size: 12px;
265                pointer-events: none;
266                z-index: 1000;
267                opacity: 0;
268                transition: opacity 0.3s;
269            }
270            
271            .voice-tooltip.show {
272                opacity: 1;
273            }
274        `;
275        document.head.appendChild(styles);
276    }
277
278    handleStart() {
279        this.state.isListening = true;
280        this.state.errorCount = 0;
281        this.updateStatus('listening', 'Listening...');
282
283        // Save current text as baseline
284        if (!this.state.finalText) {
285            this.state.finalText = this.textarea.value;
286        }
287
288        // Start silence detection
289        this.startSilenceDetection();
290
291        // Trigger custom event
292        this.textarea.dispatchEvent(new CustomEvent('voicestart', {
293            detail: { timestamp: Date.now() }
294        }));
295    }
296
297    handleResult(event) {
298        let interimTranscript = '';
299        let finalTranscript = '';
300
301        // Process all results
302        for (let i = event.resultIndex; i < event.results.length; i++) {
303            const result = event.results[i];
304            let transcript = result[0].transcript;
305
306            if (result.isFinal) {
307                // Check for voice commands and extract them
308                if (this.config.commands) {
309                    const commandResult = this.extractAndProcessCommand(transcript);
310                    if (commandResult.commandFound) {
311                        transcript = commandResult.cleanedText;
312                        // If the entire text was just a command, skip adding it
313                        if (!transcript.trim()) {
314                            continue;
315                        }
316                    }
317                }
318
319                // Process final text
320                finalTranscript += this.processTranscript(transcript);
321
322                // Add to history for undo
323                if (finalTranscript) {
324                    this.state.history.push({
325                        text: finalTranscript,
326                        timestamp: Date.now()
327                    });
328                }
329
330                // Limit history size
331                if (this.state.history.length > 50) {
332                    this.state.history.shift();
333                }
334            } else {
335                interimTranscript += transcript;
336            }
337        }
338
339        // Update text
340        if (finalTranscript) {
341            this.state.finalText += finalTranscript;
342        }
343
344        this.state.interimText = interimTranscript;
345        this.updateTextarea();
346
347        // Reset silence timer
348        this.state.lastSpeechTime = Date.now();
349
350        // Update visual feedback
351        this.updateStatus('listening', 'Listening...');
352        this.updateVoiceLevel(this.calculateVoiceLevel(event));
353
354        // Trigger custom event
355        this.textarea.dispatchEvent(new CustomEvent('voiceresult', {
356            detail: {
357                final: finalTranscript,
358                interim: interimTranscript,
359                timestamp: Date.now()
360            }
361        }));
362    }
363
364    processTranscript(text) {
365        let processed = text.trim();
366
367        // Auto-capitalize first letter
368        if (this.config.autoCapitalize) {
369            if (this.state.finalText.length === 0 ||
370                this.state.finalText.match(/[.!?]\s*$/)) {
371                processed = processed.charAt(0).toUpperCase() + processed.slice(1);
372            }
373        }
374
375        // Auto-punctuation (basic)
376        if (this.config.autoPunctuation) {
377            // Add space before if needed
378            if (this.state.finalText.length > 0 &&
379                !this.state.finalText.match(/\s$/)) {
380                processed = ' ' + processed;
381            }
382
383            // Add period at end if missing
384            if (processed.length > 10 && !processed.match(/[.!?,;:]$/)) {
385                // Only add period if it seems like end of sentence
386                const lastWords = processed.split(' ').slice(-3).join(' ').toLowerCase();
387                if (!lastWords.match(/(and|or|but|with|for|to|the|a|an)$/)) {
388                    processed += '.';
389                }
390            }
391        }
392
393        return processed;
394    }
395
396    processCommand(text) {
397        const normalized = text.trim().toLowerCase();
398
399        for (const [command, action] of Object.entries(this.voiceCommands)) {
400            if (normalized === command || normalized.endsWith(command)) {
401                // Special handling for submit commands
402                if (command === 'enter' || command === 'submit' || command === 'send' ||
403                    command === 'submit form' || command === 'send message') {
404                    action();
405                } else {
406                    action();
407                    this.showTooltip(`Command: ${command}`);
408                }
409                return true;
410            }
411        }
412
413        return false;
414    }
415
416    extractAndProcessCommand(text) {
417        const trimmedText = text.trim();
418        let commandFound = false;
419        let cleanedText = trimmedText;
420        let executedCommand = null;
421
422        // If removeCommandWords is disabled, just check for commands but don't remove them
423        if (!this.config.removeCommandWords) {
424            const commandFound = this.processCommand(trimmedText);
425            return {
426                commandFound: commandFound,
427                cleanedText: trimmedText,
428                command: null
429            };
430        }
431
432        // Check each command to see if it appears at the end of the text
433        for (const [command, action] of Object.entries(this.voiceCommands)) {
434            // Create regex patterns for different command positions
435            const patterns = [
436                // Command at the end with optional punctuation
437                new RegExp(`(.*)\\s+${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i'),
438                // Command alone
439                new RegExp(`^${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i'),
440                // Command at end after punctuation
441                new RegExp(`(.*[.!?])\\s*${command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.,!?]?$`, 'i')
442            ];
443
444            for (const pattern of patterns) {
445                const match = trimmedText.match(pattern);
446                if (match) {
447                    commandFound = true;
448                    executedCommand = command;
449
450                    // Extract the text without the command
451                    if (match[1] !== undefined) {
452                        cleanedText = match[1].trim();
453                    } else {
454                        cleanedText = '';
455                    }
456
457                    // Execute the command after a short delay to allow text update first
458                    setTimeout(() => {
459                        // Special handling for submit commands
460                        if (command === 'enter' || command === 'submit' || command === 'send' ||
461                            command === 'submit form' || command === 'send message') {
462                            action();
463                        } else {
464                            action();
465                            this.showTooltip(`Command: ${command}`);
466                        }
467                    }, 100);
468
469                    break;
470                }
471            }
472
473            if (commandFound) break;
474        }
475
476        return {
477            commandFound: commandFound,
478            cleanedText: cleanedText,
479            command: executedCommand
480        };
481    }
482
483    insertText(text) {
484        const pos = this.state.cursorPosition || this.textarea.selectionStart;
485        const before = this.state.finalText.substring(0, pos);
486        const after = this.state.finalText.substring(pos);
487
488        this.state.finalText = before + text + after;
489        this.state.cursorPosition = pos + text.length;
490
491        this.updateTextarea();
492        this.textarea.setSelectionRange(this.state.cursorPosition, this.state.cursorPosition);
493    }
494
495    updateTextarea() {
496        const displayText = this.state.finalText +
497            (this.state.interimText ? ' ' + this.state.interimText : '');
498
499        this.textarea.value = displayText;
500
501        // Trigger input event for form validation
502        this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
503
504        // Auto-scroll to bottom
505        this.textarea.scrollTop = this.textarea.scrollHeight;
506    }
507
508    handleError(event) {
509        this.state.errorCount++;
510
511        let message = 'Voice recognition error';
512        let shouldRestart = this.config.autoRestart;
513
514        switch (event.error) {
515            case 'no-speech':
516                message = 'No speech detected';
517                shouldRestart = false;
518                break;
519            case 'audio-capture':
520                message = 'Microphone not found';
521                shouldRestart = false;
522                this.checkbox.checked = false;
523                break;
524            case 'not-allowed':
525                message = 'Microphone access denied';
526                shouldRestart = false;
527                this.checkbox.checked = false;
528                break;
529            case 'network':
530                message = 'Network error occurred';
531                break;
532            case 'aborted':
533                message = 'Recognition aborted';
534                shouldRestart = false;
535                break;
536            default:
537                message = `Error: ${event.error}`;
538        }
539
540        this.updateStatus('error', message);
541        this.showError(message);
542
543        // Auto-restart if appropriate
544        if (shouldRestart && this.state.errorCount < 3 && this.checkbox.checked && !this.isSubmitting) {
545            setTimeout(() => {
546                if (this.checkbox.checked && !this.isSubmitting) {
547                    this.start();
548                }
549            }, 1000);
550        } else if (this.state.errorCount >= 3) {
551            this.stop();
552            this.showError('Too many errors. Voice typing stopped.');
553        }
554
555        // Trigger custom event
556        this.textarea.dispatchEvent(new CustomEvent('voiceerror', {
557            detail: { error: event.error, message }
558        }));
559    }
560
561    handleEnd() {
562        // Only restart if checkbox is still checked and no errors and not submitting
563        if (this.checkbox.checked && this.state.errorCount < 3 &&
564            this.config.autoRestart && !this.isSubmitting) {
565            // Small delay to prevent rapid restarts
566            setTimeout(() => {
567                if (this.checkbox.checked && !this.state.isListening && !this.isSubmitting) {
568                    this.start();
569                }
570            }, 100);
571        } else {
572            this.state.isListening = false;
573            this.updateStatus('ready', 'Ready');
574            this.hideIndicator();
575        }
576    }
577
578    handleSpeechEnd() {
579        this.updateStatus('processing', 'Processing...');
580    }
581
582    handleNoMatch() {
583        this.showTooltip('No match found. Try speaking more clearly.');
584    }
585
586    startSilenceDetection() {
587        // Clear existing timer
588        if (this.state.silenceTimer) {
589            clearInterval(this.state.silenceTimer);
590        }
591
592        // Check for prolonged silence
593        this.state.silenceTimer = setInterval(() => {
594            const silenceDuration = Date.now() - this.state.lastSpeechTime;
595
596            // Auto-stop after 30 seconds of silence
597            if (silenceDuration > 30000) {
598                this.pause();
599                this.showTooltip('Paused due to inactivity. Click to resume.');
600            }
601        }, 5000);
602    }
603
604    calculateVoiceLevel(event) {
605        // Simulate voice level based on confidence scores
606        if (!event.results.length) return 0;
607
608        const lastResult = event.results[event.results.length - 1];
609        const confidence = lastResult[0].confidence || 0.5;
610
611        return Math.min(100, confidence * 120);
612    }
613
614    updateVoiceLevel(level) {
615        if (!this.indicator) return;
616
617        const levelBar = this.indicator.querySelector('.voice-level');
618        if (levelBar) {
619            levelBar.style.width = `${level}%`;
620        }
621    }
622
623    updateStatus(status, text) {
624        if (!this.indicator) return;
625
626        const statusIcon = this.indicator.querySelector('.status-icon');
627        const statusText = this.indicator.querySelector('.status-text');
628
629        if (statusIcon) {
630            statusIcon.className = `status-icon ${status}`;
631        }
632
633        if (statusText) {
634            statusText.textContent = text;
635        }
636
637        // Show/hide indicator
638        if (status === 'listening' || status === 'processing') {
639            this.indicator.classList.add('active');
640        }
641    }
642
643    hideIndicator() {
644        if (this.indicator) {
645            setTimeout(() => {
646                this.indicator.classList.remove('active');
647            }, 500);
648        }
649    }
650
651    start() {
652        if (!this.recognition || this.isSubmitting) {
653            if (this.isSubmitting) {
654                this.showError('Cannot start during submission');
655            } else {
656                this.showError('Speech recognition not initialized');
657            }
658            return;
659        }
660
661        // Save current text
662        this.state.finalText = this.textarea.value;
663        this.state.interimText = '';
664        this.state.lastSpeechTime = Date.now();
665
666        try {
667            this.recognition.start();
668            this.checkbox.checked = true;
669        } catch (e) {
670            if (e.name === 'InvalidStateError') {
671                // Already started, try to stop and restart
672                this.recognition.stop();
673                setTimeout(() => this.start(), 100);
674            } else {
675                this.showError('Failed to start: ' + e.message);
676                this.checkbox.checked = false;
677            }
678        }
679    }
680
681    stop() {
682        if (this.recognition) {
683            try {
684                this.recognition.stop();
685            } catch (e) {
686                console.warn('Error stopping recognition:', e);
687            }
688        }
689
690        if (this.state.silenceTimer) {
691            clearInterval(this.state.silenceTimer);
692        }
693
694        // Clear interim text when stopping
695        this.state.interimText = '';
696        this.updateTextarea();
697
698        this.state.isListening = false;
699        this.state.isPaused = false;
700        this.checkbox.checked = false;
701
702        this.updateStatus('ready', 'Stopped');
703        this.hideIndicator();
704
705        // Save final state
706        this.saveState();
707
708        // Trigger custom event
709        this.textarea.dispatchEvent(new CustomEvent('voicestop', {
710            detail: { timestamp: Date.now() }
711        }));
712    }
713
714    forceStop() {
715        // Immediate stop without animations for form submission
716        if (this.recognition) {
717            try {
718                this.recognition.abort(); // Use abort for immediate halt
719            } catch (e) {
720                console.warn('Error aborting recognition:', e);
721                try {
722                    this.recognition.stop();
723                } catch (e2) {
724                    console.warn('Error stopping recognition:', e2);
725                }
726            }
727        }
728
729        // Clear all timers
730        if (this.state.silenceTimer) {
731            clearInterval(this.state.silenceTimer);
732        }
733
734        // Finalize any interim text
735        if (this.state.interimText) {
736            this.state.finalText += ' ' + this.state.interimText;
737            this.state.interimText = '';
738        }
739
740        // Update textarea with final text only
741        this.textarea.value = this.state.finalText.trim();
742
743        // Reset state immediately
744        this.state.isListening = false;
745        this.state.isPaused = false;
746        this.checkbox.checked = false;
747
748        // Hide indicators immediately
749        if (this.indicator) {
750            this.indicator.classList.remove('active');
751        }
752
753        this.updateStatus('ready', 'Stopped');
754    }
755
756    pause() {
757        if (this.recognition && this.state.isListening) {
758            this.recognition.stop();
759            this.state.isPaused = true;
760            this.updateStatus('ready', 'Paused');
761            this.showTooltip('Voice typing paused');
762        }
763    }
764
765    resume() {
766        if (this.state.isPaused && !this.isSubmitting) {
767            this.state.isPaused = false;
768            this.start();
769            this.showTooltip('Voice typing resumed');
770        }
771    }
772
773    clearText() {
774        this.state.history.push({
775            text: this.state.finalText,
776            timestamp: Date.now()
777        });
778
779        this.state.finalText = '';
780        this.state.interimText = '';
781        this.updateTextarea();
782        this.showTooltip('Text cleared');
783    }
784
785    submitForm() {
786        // Check if auto-submit is enabled
787        if (!this.config.autoSubmit) {
788            this.showTooltip('Auto-submit is disabled', 'info');
789            return;
790        }
791
792        // Prevent duplicate submissions
793        if (this.isSubmitting) {
794            return;
795        }
796        this.isSubmitting = true;
797
798        // Optional confirmation
799        if (this.config.confirmSubmit) {
800            if (!confirm('Submit the message?')) {
801                this.isSubmitting = false;
802                if (this.checkbox.checked) {
803                    setTimeout(() => this.start(), 100);
804                }
805                return;
806            }
807        }
808
809        // Force stop voice recognition
810        this.forceStop();
811
812        // Find and submit the form
813        const form = this.textarea.closest('form');
814        if (form) {
815            // Ensure the textarea has the final text (without interim)
816            this.textarea.value = this.state.finalText.trim();
817
818            if (this.textarea.value.trim()) {
819                this.showTooltip('Submitting...');
820
821                // Use requestSubmit for proper event handling
822                if (form.requestSubmit) {
823                    form.requestSubmit();
824                } else {
825                    const submitBtn = form.querySelector('[type="submit"]');
826                    if (submitBtn) {
827                        submitBtn.click();
828                    } else {
829                        form.submit();
830                    }
831                }
832
833                // Trigger custom event
834                this.textarea.dispatchEvent(new CustomEvent('voicesubmit', {
835                    detail: {
836                        text: this.textarea.value,
837                        timestamp: Date.now()
838                    }
839                }));
840
841                // Reset submission flag after delay
842                setTimeout(() => {
843                    this.isSubmitting = false;
844                }, 1000);
845            } else {
846                this.isSubmitting = false;
847                this.showTooltip('Cannot submit empty message', 'warning');
848                setTimeout(() => {
849                    if (this.checkbox.checked) {
850                        this.start();
851                    }
852                }, 1000);
853            }
854        } else {
855            this.isSubmitting = false;
856            console.warn('No form found for submission');
857            this.showTooltip('Form not found', 'error');
858        }
859    }
860
861    undo() {
862        if (this.state.history.length > 0) {
863            const previous = this.state.history.pop();
864            this.state.finalText = previous.text;
865            this.updateTextarea();
866            this.showTooltip('Undo successful');
867        }
868    }
869
870    showError(message) {
871        console.error('[VoiceTyping]', message);
872        this.showTooltip(message, 'error');
873    }
874
875    showTooltip(message, type = 'info') {
876        // Remove existing tooltip
877        const existing = document.querySelector('.voice-tooltip');
878        if (existing) {
879            existing.remove();
880        }
881
882        // Create new tooltip
883        const tooltip = document.createElement('div');
884        tooltip.className = 'voice-tooltip';
885        tooltip.textContent = message;
886
887        // Position near checkbox
888        const rect = this.checkbox.getBoundingClientRect();
889        tooltip.style.left = `${rect.left}px`;
890        tooltip.style.top = `${rect.bottom + 10}px`;
891
892        document.body.appendChild(tooltip);
893
894        // Show tooltip
895        setTimeout(() => tooltip.classList.add('show'), 10);
896
897        // Auto-hide
898        setTimeout(() => {
899            tooltip.classList.remove('show');
900            setTimeout(() => tooltip.remove(), 300);
901        }, 3000);
902    }
903
904    saveState() {
905        const state = {
906            text: this.textarea.value,
907            timestamp: Date.now()
908        };
909
910        try {
911            localStorage.setItem(`voice-typing-${this.textarea.id}`, JSON.stringify(state));
912        } catch (e) {
913            console.warn('Failed to save state:', e);
914        }
915    }
916
917    loadSavedState() {
918        try {
919            const saved = localStorage.getItem(`voice-typing-${this.textarea.id}`);
920            if (saved) {
921                const state = JSON.parse(saved);
922
923                // Only restore if less than 24 hours old
924                if (Date.now() - state.timestamp < 86400000) {
925                    if (!this.textarea.value || this.textarea.value === '') {
926                        this.textarea.value = state.text;
927                    }
928                }
929            }
930        } catch (e) {
931            console.warn('Failed to load state:', e);
932        }
933    }
934
935    // Public API methods
936    setLanguage(lang) {
937        this.config.lang = lang;
938        if (this.recognition) {
939            this.recognition.lang = lang;
940        }
941
942        // Restart if currently listening
943        if (this.state.isListening) {
944            this.stop();
945            setTimeout(() => this.start(), 100);
946        }
947    }
948
949    addCommand(trigger, action) {
950        this.voiceCommands[trigger.toLowerCase()] = action;
951    }
952
953    removeCommand(trigger) {
954        delete this.voiceCommands[trigger.toLowerCase()];
955    }
956
957    destroy() {
958        this.forceStop();
959
960        if (this.indicator) {
961            this.indicator.remove();
962        }
963
964        // Clear state
965        this.state = null;
966        this.recognition = null;
967    }
968}
969
970// Initialize with enhanced features
971const voiceTyping = new EnhancedVoiceTyping('asked-question', 'voicememo', {
972    lang: 'en-US',
973    autoRestart: true,
974    autoCapitalize: true,
975    autoPunctuation: true,
976    commands: true,
977    visualFeedback: true,
978    continuous: true,
979    interimResults: true,
980    autoSubmit: true,
981    confirmSubmit: false,
982    removeCommandWords: true
983});
984
985// Expose to global scope
986window.voiceTyping = voiceTyping;
987
988// Global error handler for voice recognition
989window.addEventListener('unhandledrejection', function (event) {
990    if (window.voiceTyping && window.voiceTyping.state && window.voiceTyping.state.isListening) {
991        console.error('Voice typing error:', event.reason);
992        window.voiceTyping.forceStop();
993    }
994});

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.