/** * PHP-WASM Voice Component * * A reusable component that adds voice control functionality to PHP-WASM applications */ class VoiceComponent { /** * Create a new voice component * @param {Object} options - Configuration options */ constructor(options = {}) { // Default options this.options = { containerId: options.containerId || 'voice-component', language: options.language || 'en-US', continuous: options.continuous !== false, interimResults: options.interimResults === true, maxAlternatives: options.maxAlternatives || 1, autoStart: options.autoStart === true, commands: options.commands || {}, customActions: options.customActions || {}, customResponsePreProcessor: options.customResponsePreProcessor, customTriggerPhrase: options.customTriggerPhrase, showTranscript: options.showTranscript !== false, useAnimation: options.useAnimation !== false, displayMode: options.displayMode || 'button', // 'button', 'bar', 'icon' position: options.position || 'bottom-right', // 'top-right', 'top-left', 'bottom-right', 'bottom-left', 'center' theme: options.theme || 'light', // 'light', 'dark', 'auto' iconMode: options.iconMode || 'microphone', // 'microphone', 'assistant', 'circle', 'custom' customIcon: options.customIcon, voiceFeedback: options.voiceFeedback !== false, confidenceThreshold: options.confidenceThreshold || 0.6, inactivityTimeout: options.inactivityTimeout || 10000, // ms // TTS options tts: { enabled: options.tts?.enabled !== false, voice: options.tts?.voice, rate: options.tts?.rate || 1.0, pitch: options.tts?.pitch || 1.0, volume: options.tts?.volume || 1.0 }, // Animation options animation: { type: options.animation?.type || 'wave', // 'wave', 'pulse', 'bars', 'circle' color: options.animation?.color || '#3498db', size: options.animation?.size || 'medium', // 'small', 'medium', 'large' speed: options.animation?.speed || 'normal' // 'slow', 'normal', 'fast' }, // Appearance styles: options.styles || {}, classes: options.classes || {}, translations: options.translations || {} }; // Internal state this.state = { isInitialized: false, isListening: false, isSpeaking: false, transcript: '', interimTranscript: '', confidence: 0, lastCommand: null, commandHistory: [], activeRequests: 0, processingCommand: false, supportsSpeechRecognition: false, supportsSpeechSynthesis: false }; // Speech recognition this.recognition = null; // Speech synthesis this.speechSynthesis = window.speechSynthesis; this.speechUtterance = null; // DOM elements this.elements = { container: null, button: null, animationContainer: null, statusIndicator: null, transcriptDisplay: null }; // Initialize this.init(); } /** * Initialize the voice component */ init() { // Check browser support this.checkBrowserSupport(); if (!this.state.supportsSpeechRecognition) { console.warn('Speech recognition is not supported in this browser'); return; } // Initialize speech recognition this.initSpeechRecognition(); // Initialize speech synthesis if supported if (this.state.supportsSpeechSynthesis) { this.initSpeechSynthesis(); } // Create UI this.createUI(); // Set up event listeners this.setupEventListeners(); // Mark as initialized this.state.isInitialized = true; // Start automatically if configured if (this.options.autoStart) { this.start(); } } /** * Check browser support for speech APIs */ checkBrowserSupport() { // Check for speech recognition this.state.supportsSpeechRecognition = ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window); // Check for speech synthesis this.state.supportsSpeechSynthesis = 'speechSynthesis' in window; } /** * Initialize speech recognition */ initSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) return; this.recognition = new SpeechRecognition(); // Configure recognition this.recognition.continuous = this.options.continuous; this.recognition.interimResults = this.options.interimResults; this.recognition.maxAlternatives = this.options.maxAlternatives; this.recognition.lang = this.options.language; // Set up recognition events this.recognition.onstart = () => { this.state.isListening = true; this.updateUI(); // Dispatch event this.dispatchEvent('start'); }; this.recognition.onend = () => { this.state.isListening = false; this.updateUI(); // Restart if continuous is enabled and not manually stopped if (this.options.continuous && this.state.isInitialized && !this.state.isManuallyStopped) { this.recognition.start(); } // Dispatch event this.dispatchEvent('end'); }; this.recognition.onresult = (event) => { // Get the latest result const result = event.results[event.resultIndex]; const transcript = result[0].transcript.trim(); const confidence = result[0].confidence; const isFinal = result.isFinal; // Update state if (isFinal) { this.state.transcript = transcript; this.state.interimTranscript = ''; } else { this.state.interimTranscript = transcript; } this.state.confidence = confidence; // Update UI this.updateTranscript(); // Process command if final if (isFinal && confidence >= this.options.confidenceThreshold) { this.processVoiceInput(transcript, confidence); } // Dispatch event this.dispatchEvent('result', { transcript, confidence, isFinal }); }; this.recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); // Update UI this.showStatus('error', `Error: ${event.error}`); // Dispatch event this.dispatchEvent('error', { error: event.error }); // Restart if not aborted if (event.error !== 'aborted' && this.state.isListening) { setTimeout(() => { this.restart(); }, 1000); } }; } /** * Initialize speech synthesis */ initSpeechSynthesis() { if (!this.state.supportsSpeechSynthesis) return; // Set up voice when voices are available if (speechSynthesis.getVoices().length === 0) { speechSynthesis.addEventListener('voiceschanged', () => { this.selectVoice(); }); } else { this.selectVoice(); } } /** * Select a voice for speech synthesis */ selectVoice() { const voices = speechSynthesis.getVoices(); if (voices.length === 0) { console.warn('No voices available for speech synthesis'); return; } // If voice is specified by name, try to find it if (typeof this.options.tts.voice === 'string') { const matchingVoice = voices.find(v => v.name.toLowerCase().includes(this.options.tts.voice.toLowerCase()) ); if (matchingVoice) { this.options.tts.voice = matchingVoice; return; } } // If voice is not found or not specified, use a default voice // Prefer a voice in the specified language const langVoices = voices.filter(v => v.lang.startsWith(this.options.language.split('-')[0])); if (langVoices.length > 0) { this.options.tts.voice = langVoices[0]; } else { // Use the first available voice this.options.tts.voice = voices[0]; } } /** * Create the UI elements */ createUI() { // Find or create container let container = document.getElementById(this.options.containerId); if (!container) { container = document.createElement('div'); container.id = this.options.containerId; document.body.appendChild(container); } // Clear container container.innerHTML = ''; // Set container styles based on position const positionStyles = { 'top-right': 'position: fixed; top: 20px; right: 20px;', 'top-left': 'position: fixed; top: 20px; left: 20px;', 'bottom-right': 'position: fixed; bottom: 20px; right: 20px;', 'bottom-left': 'position: fixed; bottom: 20px; left: 20px;', 'center': 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);' }; // Apply base styles container.style.cssText = ` z-index: 9999; ${positionStyles[this.options.position] || positionStyles['bottom-right']} ${this.options.styles.container || ''} `; // Apply theme container.classList.add(`voice-component-${this.options.theme}`); if (this.options.classes.container) { container.classList.add(this.options.classes.container); } // Create UI based on display mode switch (this.options.displayMode) { case 'button': this.createButtonUI(container); break; case 'bar': this.createBarUI(container); break; case 'icon': this.createIconUI(container); break; default: this.createButtonUI(container); } // Store container reference this.elements.container = container; // Add styles this.addStyles(); } /** * Create button UI * @param {HTMLElement} container - The container element */ createButtonUI(container) { // Create button const button = document.createElement('button'); button.className = 'voice-component-button'; button.setAttribute('aria-label', this.translate('activateVoice')); // Add icon button.innerHTML = this.getIconSVG(); // Add status indicator const statusIndicator = document.createElement('span'); statusIndicator.className = 'voice-component-status'; button.appendChild(statusIndicator); // Add to container container.appendChild(button); // Create animation container const animationContainer = document.createElement('div'); animationContainer.className = 'voice-component-animation'; container.appendChild(animationContainer); // Create transcript display if enabled if (this.options.showTranscript) { const transcriptDisplay = document.createElement('div'); transcriptDisplay.className = 'voice-component-transcript'; container.appendChild(transcriptDisplay); this.elements.transcriptDisplay = transcriptDisplay; } // Store element references this.elements.button = button; this.elements.statusIndicator = statusIndicator; this.elements.animationContainer = animationContainer; } /** * Create bar UI * @param {HTMLElement} container - The container element */ createBarUI(container) { // Create bar container const bar = document.createElement('div'); bar.className = 'voice-component-bar'; // Create button const button = document.createElement('button'); button.className = 'voice-component-bar-button'; button.setAttribute('aria-label', this.translate('activateVoice')); // Add icon button.innerHTML = this.getIconSVG(); // Add status indicator const statusIndicator = document.createElement('span'); statusIndicator.className = 'voice-component-status'; button.appendChild(statusIndicator); // Add to bar bar.appendChild(button); // Create animation container const animationContainer = document.createElement('div'); animationContainer.className = 'voice-component-animation'; bar.appendChild(animationContainer); // Create transcript display const transcriptDisplay = document.createElement('div'); transcriptDisplay.className = 'voice-component-transcript'; bar.appendChild(transcriptDisplay); // Add to container container.appendChild(bar); // Store element references this.elements.button = button; this.elements.statusIndicator = statusIndicator; this.elements.animationContainer = animationContainer; this.elements.transcriptDisplay = transcriptDisplay; } /** * Create icon UI * @param {HTMLElement} container - The container element */ createIconUI(container) { // Create icon button const button = document.createElement('button'); button.className = 'voice-component-icon'; button.setAttribute('aria-label', this.translate('activateVoice')); // Add icon button.innerHTML = this.getIconSVG(); // Add status indicator const statusIndicator = document.createElement('span'); statusIndicator.className = 'voice-component-status'; button.appendChild(statusIndicator); // Add to container container.appendChild(button); // Create animation container const animationContainer = document.createElement('div'); animationContainer.className = 'voice-component-animation'; container.appendChild(animationContainer); // Create transcript display if enabled if (this.options.showTranscript) { const transcriptDisplay = document.createElement('div'); transcriptDisplay.className = 'voice-component-transcript voice-component-transcript-popup'; container.appendChild(transcriptDisplay); this.elements.transcriptDisplay = transcriptDisplay; } // Store element references this.elements.button = button; this.elements.statusIndicator = statusIndicator; this.elements.animationContainer = animationContainer; } /** * Get the icon SVG based on the icon mode * @returns {string} - The icon SVG */ getIconSVG() { switch (this.options.iconMode) { case 'microphone': return ` `; case 'assistant': return ` `; case 'circle': return ` `; case 'custom': return this.options.customIcon || this.getIconSVG('microphone'); default: return this.getIconSVG('microphone'); } } /** * Add CSS styles to the document */ addStyles() { // Check if styles already exist if (document.getElementById('voice-component-styles')) return; // Create style element const style = document.createElement('style'); style.id = 'voice-component-styles'; // Set animation speed let animationSpeed = '1s'; switch (this.options.animation.speed) { case 'slow': animationSpeed = '2s'; break; case 'fast': animationSpeed = '0.5s'; break; } // Set animation size let animationSize = '50px'; switch (this.options.animation.size) { case 'small': animationSize = '30px'; break; case 'large': animationSize = '70px'; break; } // Add base styles style.textContent = ` /* Base styles */ .voice-component-light { --voice-primary-color: ${this.options.animation.color}; --voice-text-color: #333; --voice-bg-color: #fff; --voice-border-color: #ddd; --voice-shadow-color: rgba(0, 0, 0, 0.1); } .voice-component-dark { --voice-primary-color: ${this.options.animation.color}; --voice-text-color: #fff; --voice-bg-color: #333; --voice-border-color: #555; --voice-shadow-color: rgba(0, 0, 0, 0.3); } @media (prefers-color-scheme: dark) { .voice-component-auto { --voice-primary-color: ${this.options.animation.color}; --voice-text-color: #fff; --voice-bg-color: #333; --voice-border-color: #555; --voice-shadow-color: rgba(0, 0, 0, 0.3); } } @media (prefers-color-scheme: light) { .voice-component-auto { --voice-primary-color: ${this.options.animation.color}; --voice-text-color: #333; --voice-bg-color: #fff; --voice-border-color: #ddd; --voice-shadow-color: rgba(0, 0, 0, 0.1); } } /* Button styles */ .voice-component-button { width: 48px; height: 48px; border-radius: 50%; background-color: var(--voice-bg-color); color: var(--voice-text-color); border: 1px solid var(--voice-border-color); box-shadow: 0 2px 5px var(--voice-shadow-color); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; position: relative; overflow: hidden; padding: 0; } .voice-component-button:hover { transform: scale(1.05); box-shadow: 0 4px 8px var(--voice-shadow-color); } .voice-component-button:active { transform: scale(0.95); } .voice-component-button svg { width: 24px; height: 24px; transition: all 0.3s ease; } .voice-component-button.listening { background-color: var(--voice-primary-color); color: white; } /* Icon styles */ .voice-component-icon { width: 40px; height: 40px; border-radius: 50%; background-color: var(--voice-bg-color); color: var(--voice-text-color); border: 1px solid var(--voice-border-color); box-shadow: 0 2px 5px var(--voice-shadow-color); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; position: relative; overflow: hidden; padding: 0; } .voice-component-icon:hover { transform: scale(1.05); box-shadow: 0 4px 8px var(--voice-shadow-color); } .voice-component-icon.listening { background-color: var(--voice-primary-color); color: white; } .voice-component-icon svg { width: 20px; height: 20px; } /* Bar styles */ .voice-component-bar { display: flex; align-items: center; background-color: var(--voice-bg-color); border: 1px solid var(--voice-border-color); border-radius: 24px; box-shadow: 0 2px 5px var(--voice-shadow-color); padding: 6px; min-width: 200px; transition: all 0.3s ease; } .voice-component-bar-button { width: 36px; height: 36px; border-radius: 50%; background-color: var(--voice-bg-color); color: var(--voice-text-color); border: 1px solid var(--voice-border-color); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; padding: 0; flex-shrink: 0; } .voice-component-bar-button.listening { background-color: var(--voice-primary-color); color: white; } .voice-component-bar-button svg { width: 18px; height: 18px; } /* Status indicator */ .voice-component-status { position: absolute; bottom: 5px; right: 5px; width: 8px; height: 8px; border-radius: 50%; background-color: #ccc; transition: background-color 0.3s ease; } .listening .voice-component-status { background-color: #4CAF50; } .processing .voice-component-status { background-color: #FFC107; } .error .voice-component-status { background-color: #F44336; } /* Transcript display */ .voice-component-transcript { margin-top: 10px; padding: 8px 12px; background-color: var(--voice-bg-color); color: var(--voice-text-color); border-radius: 8px; border: 1px solid var(--voice-border-color); box-shadow: 0 2px 5px var(--voice-shadow-color); font-size: 14px; max-width: 300px; word-wrap: break-word; transition: all 0.3s ease; opacity: 0; } .voice-component-transcript.active { opacity: 1; } .voice-component-transcript-popup { position: absolute; bottom: 50px; right: 0; min-width: 200px; } /* Animation container */ .voice-component-animation { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: ${animationSize}; height: ${animationSize}; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; } .voice-component-animation.active { opacity: 1; } /* Wave animation */ .voice-component-animation.wave .wave { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; height: 100%; border-radius: 50%; background-color: var(--voice-primary-color); opacity: 0; animation: wave-animation ${animationSpeed} infinite ease-out; } .voice-component-animation.wave .wave:nth-child(2) { animation-delay: 0.2s; } .voice-component-animation.wave .wave:nth-child(3) { animation-delay: 0.4s; } @keyframes wave-animation { 0% { transform: translate(-50%, -50%) scale(0); opacity: 1; } 70% { opacity: 0.2; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 0; } } /* Pulse animation */ .voice-component-animation.pulse .pulse { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; height: 100%; border-radius: 50%; border: 2px solid var(--voice-primary-color); opacity: 0; animation: pulse-animation ${animationSpeed} infinite ease-out; } @keyframes pulse-animation { 0% { transform: translate(-50%, -50%) scale(0.3); opacity: 1; } 70% { opacity: 0.3; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 0; } } /* Bars animation */ .voice-component-animation.bars { display: flex; justify-content: center; align-items: center; gap: 2px; } .voice-component-animation.bars .bar { width: 3px; height: 16px; background-color: var(--voice-primary-color); border-radius: 3px; animation: bars-animation ${animationSpeed} infinite ease-in-out; } .voice-component-animation.bars .bar:nth-child(2) { animation-delay: 0.2s; } .voice-component-animation.bars .bar:nth-child(3) { animation-delay: 0.4s; } .voice-component-animation.bars .bar:nth-child(4) { animation-delay: 0.6s; } .voice-component-animation.bars .bar:nth-child(5) { animation-delay: 0.8s; } @keyframes bars-animation { 0%, 100% { height: 4px; } 50% { height: 16px; } } /* Circle animation */ .voice-component-animation.circle .circle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 50%; height: 50%; border-radius: 50%; background-color: var(--voice-primary-color); animation: circle-animation ${animationSpeed} infinite ease-in-out; } @keyframes circle-animation { 0%, 100% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.2; } 50% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; } } `; // Add to document document.head.appendChild(style); } /** * Set up event listeners */ setupEventListeners() { // Button click event if (this.elements.button) { this.elements.button.addEventListener('click', () => { if (this.state.isListening) { this.stop(); } else { this.start(); } }); } // Add global listener for custom trigger phrase if provided if (this.options.customTriggerPhrase && this.options.continuous) { this.recognition.addEventListener('result', (event) => { const result = event.results[event.resultIndex]; const transcript = result[0].transcript.trim().toLowerCase(); if (transcript.includes(this.options.customTriggerPhrase.toLowerCase())) { // Highlight that the trigger was recognized this.showStatus('success', 'Trigger phrase recognized'); // Dispatch trigger event this.dispatchEvent('trigger', { transcript }); } }); } // Set up inactivity timeout if specified if (this.options.inactivityTimeout > 0) { let inactivityTimer; // Reset timer on recognition result this.recognition.addEventListener('result', () => { clearTimeout(inactivityTimer); // Set new timer inactivityTimer = setTimeout(() => { if (this.state.isListening && !this.state.isSpeaking) { this.speak(this.translate('inactivityTimeout')); this.stop(); } }, this.options.inactivityTimeout); }); // Clear timer on stop this.recognition.addEventListener('end', () => { clearTimeout(inactivityTimer); }); } } /** * Update the UI based on the current state */ updateUI() { if (!this.elements.button) return; // Update button state if (this.state.isListening) { this.elements.button.classList.add('listening'); this.elements.button.setAttribute('aria-label', this.translate('stopListening')); // Show animation if enabled if (this.options.useAnimation) { this.showAnimation(); } } else { this.elements.button.classList.remove('listening'); this.elements.button.setAttribute('aria-label', this.translate('startListening')); // Hide animation this.hideAnimation(); } // Update status indicator if (this.state.processingCommand) { this.elements.button.classList.add('processing'); } else { this.elements.button.classList.remove('processing'); } } /** * Update the transcript display */ updateTranscript() { if (!this.elements.transcriptDisplay || !this.options.showTranscript) return; const transcript = this.state.transcript || this.state.interimTranscript; if (transcript) { this.elements.transcriptDisplay.textContent = transcript; this.elements.transcriptDisplay.classList.add('active'); // Hide transcript after a delay if (!this.state.isListening && !this.state.interimTranscript) { setTimeout(() => { this.elements.transcriptDisplay.classList.remove('active'); }, 5000); } } else { this.elements.transcriptDisplay.classList.remove('active'); } } /** * Show animation based on animation type */ showAnimation() { if (!this.elements.animationContainer) return; // Clear existing animation this.elements.animationContainer.innerHTML = ''; this.elements.animationContainer.className = 'voice-component-animation'; // Add animation class this.elements.animationContainer.classList.add(this.options.animation.type); // Create animation elements based on type switch (this.options.animation.type) { case 'wave': for (let i = 0; i < 3; i++) { const wave = document.createElement('div'); wave.className = 'wave'; this.elements.animationContainer.appendChild(wave); } break; case 'pulse': const pulse = document.createElement('div'); pulse.className = 'pulse'; this.elements.animationContainer.appendChild(pulse); break; case 'bars': for (let i = 0; i < 5; i++) { const bar = document.createElement('div'); bar.className = 'bar'; this.elements.animationContainer.appendChild(bar); } break; case 'circle': const circle = document.createElement('div'); circle.className = 'circle'; this.elements.animationContainer.appendChild(circle); break; } // Show animation this.elements.animationContainer.classList.add('active'); } /** * Hide animation */ hideAnimation() { if (!this.elements.animationContainer) return; this.elements.animationContainer.classList.remove('active'); } /** * Show status * @param {string} type - Status type (success, error, info) * @param {string} message - Status message */ showStatus(type, message) { if (!this.elements.button) return; // Add status class this.elements.button.classList.remove('success', 'error', 'info'); this.elements.button.classList.add(type); // Remove status class after a delay setTimeout(() => { this.elements.button.classList.remove(type); }, 2000); // Update transcript if available if (this.elements.transcriptDisplay && message) { this.elements.transcriptDisplay.textContent = message; this.elements.transcriptDisplay.classList.add('active'); // Hide after delay setTimeout(() => { this.elements.transcriptDisplay.classList.remove('active'); }, 3000); } } /** * Process voice input * @param {string} transcript - The voice transcript * @param {number} confidence - The confidence level */ processVoiceInput(transcript, confidence) { // Skip processing if confidence is too low if (confidence < this.options.confidenceThreshold) { this.showStatus('error', this.translate('lowConfidence')); return; } // Add to command history this.state.commandHistory.push({ transcript, confidence, timestamp: new Date() }); // Limit history size if (this.state.commandHistory.length > 50) { this.state.commandHistory.shift(); } // Pre-process the transcript if custom processor is provided if (typeof this.options.customResponsePreProcessor === 'function') { transcript = this.options.customResponsePreProcessor(transcript) || transcript; } // Mark as processing this.state.processingCommand = true; this.updateUI(); // Check for built-in commands const isBuiltInCommand = this.processBuiltInCommands(transcript); if (!isBuiltInCommand) { // Check for custom commands const isCustomCommand = this.processCustomCommands(transcript); if (!isCustomCommand) { // No command matched this.showStatus('info', this.translate('noCommandMatch')); // Dispatch unrecognized event this.dispatchEvent('unrecognized', { transcript, confidence }); } } // Mark as not processing this.state.processingCommand = false; this.updateUI(); } /** * Process built-in commands * @param {string} transcript - The voice transcript * @returns {boolean} - Whether a built-in command was processed */ processBuiltInCommands(transcript) { const lowerTranscript = transcript.toLowerCase(); // Stop command if (this.matchesCommand(lowerTranscript, ['stop', 'stop listening', 'exit', 'quit'])) { this.stop(); return true; } // Help command if (this.matchesCommand(lowerTranscript, ['help', 'what can I say', 'commands'])) { this.speak(this.translate('helpMessage')); return true; } // No built-in command matched return false; } /** * Process custom commands * @param {string} transcript - The voice transcript * @returns {boolean} - Whether a custom command was processed */ processCustomCommands(transcript) { const lowerTranscript = transcript.toLowerCase(); // Check for custom commands for (const commandPattern in this.options.commands) { // Convert command pattern to regex if it has placeholders if (commandPattern.includes('*')) { const regexPattern = '^' + commandPattern.replace(/\*/g, '(.+)').replace(/\s+/g, '\\s+') + '$'; const regex = new RegExp(regexPattern, 'i'); const match = lowerTranscript.match(regex); if (match) { // Extract parameters const params = match.slice(1); // Get command handler const handler = this.options.commands[commandPattern]; if (typeof handler === 'function') { // Execute command with parameters handler(...params); // Update last command this.state.lastCommand = { pattern: commandPattern, params, transcript }; // Dispatch command event this.dispatchEvent('command', { pattern: commandPattern, params, transcript }); return true; } } } else if (this.matchesCommand(lowerTranscript, [commandPattern])) { // Simple command without placeholders const handler = this.options.commands[commandPattern]; if (typeof handler === 'function') { // Execute command handler(); // Update last command this.state.lastCommand = { pattern: commandPattern, transcript }; // Dispatch command event this.dispatchEvent('command', { pattern: commandPattern, transcript }); return true; } } } // No custom command matched return false; } /** * Check if transcript matches any of the command phrases * @param {string} transcript - The transcript to check * @param {Array} phrases - The phrases to match * @returns {boolean} - Whether there's a match */ matchesCommand(transcript, phrases) { return phrases.some(phrase => transcript.includes(phrase.toLowerCase())); } /** * Start listening for voice commands */ start() { if (!this.state.supportsSpeechRecognition) { this.showStatus('error', this.translate('notSupported')); return; } if (this.state.isListening) { return; } try { this.recognition.start(); this.state.isManuallyStopped = false; this.showStatus('success', this.translate('listening')); } catch (error) { console.error('Failed to start speech recognition:', error); this.showStatus('error', this.translate('startError')); } } /** * Stop listening for voice commands */ stop() { if (!this.state.isListening) { return; } try { this.recognition.stop(); this.state.isManuallyStopped = true; this.showStatus('info', this.translate('stopped')); } catch (error) { console.error('Failed to stop speech recognition:', error); } } /** * Restart speech recognition */ restart() { this.stop(); setTimeout(() => { this.start(); }, 200); } /** * Speak text using speech synthesis * @param {string} text - The text to speak * @returns {Promise} - Promise that resolves when speech is complete */ speak(text) { if (!this.state.supportsSpeechSynthesis || !this.options.tts.enabled) { return Promise.resolve(); } return new Promise((resolve) => { // Cancel any ongoing speech speechSynthesis.cancel(); // Create utterance const utterance = new SpeechSynthesisUtterance(text); // Set utterance properties if (this.options.tts.voice) { utterance.voice = this.options.tts.voice; } utterance.volume = this.options.tts.volume; utterance.rate = this.options.tts.rate; utterance.pitch = this.options.tts.pitch; // Set up event listeners utterance.onstart = () => { this.state.isSpeaking = true; // Pause recognition while speaking if (this.state.isListening) { this.recognition.stop(); } }; utterance.onend = () => { this.state.isSpeaking = false; // Resume recognition if it was active if (this.state.isListening && !this.state.isManuallyStopped) { setTimeout(() => { this.recognition.start(); }, 100); } resolve(); }; utterance.onerror = (error) => { console.error('Speech synthesis error:', error); this.state.isSpeaking = false; // Resume recognition if it was active if (this.state.isListening && !this.state.isManuallyStopped) { setTimeout(() => { this.recognition.start(); }, 100); } resolve(); }; // Speak the utterance speechSynthesis.speak(utterance); }); } /** * Add a command * @param {string} pattern - The command pattern * @param {Function} handler - The command handler */ addCommand(pattern, handler) { if (typeof pattern !== 'string' || typeof handler !== 'function') { console.error('Invalid command:', pattern); return; } this.options.commands[pattern] = handler; } /** * Remove a command * @param {string} pattern - The command pattern to remove * @returns {boolean} - Whether the command was removed */ removeCommand(pattern) { if (this.options.commands.hasOwnProperty(pattern)) { delete this.options.commands[pattern]; return true; } return false; } /** * Get all commands * @returns {Object} - All registered commands */ getCommands() { return { ...this.options.commands }; } /** * Clear all commands */ clearCommands() { this.options.commands = {}; } /** * Translate a string using the translations object * @param {string} key - The translation key * @returns {string} - The translated string */ translate(key) { // Default translations const defaultTranslations = { activateVoice: 'Activate voice control', startListening: 'Start listening', stopListening: 'Stop listening', listening: 'Listening...', stopped: 'Voice control stopped', notSupported: 'Speech recognition not supported in this browser', startError: 'Failed to start speech recognition', lowConfidence: 'Sorry, I didn\'t catch that', noCommandMatch: 'No command matched', inactivityTimeout: 'Voice control timed out due to inactivity', helpMessage: 'You can say commands like "help" or "stop listening".' }; // Get translation or default return this.options.translations[key] || defaultTranslations[key] || key; } /** * Dispatch a custom event * @param {string} name - The event name * @param {Object} detail - The event detail */ dispatchEvent(name, detail = {}) { const event = new CustomEvent(`voice:${name}`, { bubbles: true, detail: { ...detail, component: this } }); this.elements.container.dispatchEvent(event); } /** * Get the current state * @returns {Object} - The current state */ getState() { return { ...this.state }; } /** * Set TTS options * @param {Object} options - The TTS options */ setTTSOptions(options) { this.options.tts = { ...this.options.tts, ...options }; if (this.state.supportsSpeechSynthesis) { this.selectVoice(); } } /** * Set animation options * @param {Object} options - The animation options */ setAnimationOptions(options) { this.options.animation = { ...this.options.animation, ...options }; // Update styles this.addStyles(); } /** * Set language * @param {string} language - The language code (e.g., 'en-US') */ setLanguage(language) { this.options.language = language; if (this.recognition) { this.recognition.lang = language; } if (this.state.supportsSpeechSynthesis) { this.selectVoice(); } } /** * Set continuous mode * @param {boolean} continuous - Whether recognition should be continuous */ setContinuous(continuous) { this.options.continuous = continuous; if (this.recognition) { this.recognition.continuous = continuous; } } /** * Enable or disable voice feedback * @param {boolean} enabled - Whether voice feedback should be enabled */ enableVoiceFeedback(enabled) { this.options.tts.enabled = enabled; } /** * Destroy the component */ destroy() { // Stop recognition if (this.state.isListening) { this.stop(); } // Remove event listeners if (this.elements.button) { this.elements.button.removeEventListener('click', this.start); } // Remove container if it exists if (this.elements.container && this.elements.container.parentNode) { this.elements.container.parentNode.removeChild(this.elements.container); } // Reset state this.state.isInitialized = false; } } /** * Check if speech recognition is supported * @returns {boolean} - Whether speech recognition is supported */ VoiceComponent.isSupported = function() { return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window; }; /** * Check if speech synthesis is supported * @returns {boolean} - Whether speech synthesis is supported */ VoiceComponent.isTTSSupported = function() { return 'speechSynthesis' in window; }; /** * Get available voices for speech synthesis * @returns {Array} - Available voices */ VoiceComponent.getVoices = function() { if (!('speechSynthesis' in window)) { return []; } return window.speechSynthesis.getVoices(); }; // Export as a module if supported if (typeof module !== 'undefined' && module.exports) { module.exports = VoiceComponent; } // Add the component to PHP component templates if (typeof PHPComponentTemplates !== 'undefined') { PHPComponentTemplates.push({ id: 'voice-assistant', name: 'Voice Assistant', description: 'Add voice control to your PHP application', category: 'Advanced', icon: ` `, template: `
`, defaultProps: { containerId: 'voice-assistant', language: 'en-US', autoStart: 'false', displayMode: 'button', position: 'bottom-right', theme: 'light', iconMode: 'microphone', voiceFeedback: 'true', showTranscript: 'true', animationType: 'wave', animationColor: '#3498db', customCommands: '"help": function() { alert("Help requested"); },\n"go to *": function(page) { window.location.href = page + ".php"; }', commandHandler: '// Handle specific commands\nif (event.detail.pattern === "go to *") {\n // Custom handler for navigation\n}' }, properties: [ { name: 'containerId', label: 'Container ID', type: 'text', description: 'ID for the voice assistant container' }, { name: 'language', label: 'Language', type: 'select', options: [ { value: 'en-US', label: 'English (US)' }, { value: 'en-GB', label: 'English (UK)' }, { value: 'fr-FR', label: 'French' }, { value: 'de-DE', label: 'German' }, { value: 'es-ES', label: 'Spanish' }, { value: 'it-IT', label: 'Italian' }, { value: 'ja-JP', label: 'Japanese' }, { value: 'zh-CN', label: 'Chinese (Simplified)' } ], description: 'Recognition language' }, { name: 'autoStart', label: 'Auto Start', type: 'select', options: [ { value: 'true', label: 'Yes' }, { value: 'false', label: 'No' } ], description: 'Start voice recognition automatically' }, { name: 'displayMode', label: 'Display Mode', type: 'select', options: [ { value: 'button', label: 'Button' }, { value: 'icon', label: 'Icon' }, { value: 'bar', label: 'Bar' } ], description: 'How the voice assistant is displayed' }, { name: 'position', label: 'Position', type: 'select', options: [ { value: 'bottom-right', label: 'Bottom Right' }, { value: 'bottom-left', label: 'Bottom Left' }, { value: 'top-right', label: 'Top Right' }, { value: 'top-left', label: 'Top Left' }, { value: 'center', label: 'Center' } ], description: 'Position on the screen' }, { name: 'theme', label: 'Theme', type: 'select', options: [ { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, { value: 'auto', label: 'Auto (System)' } ], description: 'Color theme' }, { name: 'iconMode', label: 'Icon Style', type: 'select', options: [ { value: 'microphone', label: 'Microphone' }, { value: 'assistant', label: 'Assistant' }, { value: 'circle', label: 'Circle' } ], description: 'Icon style' }, { name: 'voiceFeedback', label: 'Voice Feedback', type: 'select', options: [ { value: 'true', label: 'Enabled' }, { value: 'false', label: 'Disabled' } ], description: 'Enable voice responses' }, { name: 'showTranscript', label: 'Show Transcript', type: 'select', options: [ { value: 'true', label: 'Yes' }, { value: 'false', label: 'No' } ], description: 'Show recognized speech' }, { name: 'animationType', label: 'Animation Type', type: 'select', options: [ { value: 'wave', label: 'Wave' }, { value: 'pulse', label: 'Pulse' }, { value: 'bars', label: 'Bars' }, { value: 'circle', label: 'Circle' } ], description: 'Type of animation' }, { name: 'animationColor', label: 'Animation Color', type: 'color', description: 'Color for the animation' }, { name: 'customCommands', label: 'Custom Commands', type: 'code', language: 'javascript', description: 'JavaScript object with command patterns and handlers' }, { name: 'commandHandler', label: 'Command Handler', type: 'code', language: 'javascript', description: 'JavaScript code to handle commands' } ] }); }