/** * BrowserTTSModule * Provides TTS via Browser's Web Speech API */ import { TTSHandlerModule } from './tts-handler-module.js'; export class BrowserTTSModule extends TTSHandlerModule { constructor() { super('browser-tts', 'Browser TTS'); // Declare proper dependencies according to architecture principles this.dependencies = ['persistence-manager', 'localization']; // Voice options this.voiceOptions = { voice: null, // Will be set during initialization speed: 1.0, pitch: 1.0, volume: 1.0 }; // State variables this.voices = []; this.voicesByLang = {}; this.lastPreprocessedText = ''; this.isSpeaking = false; this.currentUtterance = null; // Bind additional methods this.bindMethods(['handleVoicePreferenceChanged']); } /** * Initialize the Browser TTS module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, 'Initializing Browser TTS'); // Initialize parent const parentInit = await super.initialize(); if (!parentInit) { console.error('Browser TTS: Parent initialization failed'); return false; } // Get dependencies using proper pattern const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.error('Browser TTS: Persistence Manager dependency not found'); return false; } const localization = this.getModule('localization'); if (!localization) { console.error('Browser TTS: Localization dependency not found'); return false; } // Check if browser supports speech synthesis if (!window.speechSynthesis) { console.error('Browser TTS: Speech synthesis not available in this browser'); return false; } // Load voices this.reportProgress(30, 'Loading browser voices'); await this.loadVoices(); // Set up voice from preferences this.reportProgress(70, 'Setting up voice preferences'); await this.setupVoiceFromPreferences(); // Set up event listeners document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged); // Set up utterance handlers this.setupUtteranceHandlers(); // Mark as ready this.isReady = true; this.reportProgress(100, 'Browser TTS initialization complete'); return true; } catch (error) { console.error('Browser TTS: Initialization error:', error); return false; } } /** * Load voices from browser speech synthesis API * @returns {Promise} - Resolves with success status */ async loadVoices() { // Helper function to process voices const processVoices = () => { // Get all voices from speechSynthesis const synVoices = window.speechSynthesis.getVoices() || []; if (synVoices.length === 0) { console.warn('Browser TTS: No voices available'); return false; } // Transform to our format this.voices = synVoices.map((voice, index) => ({ id: voice.voiceURI || `voice-${index}`, name: voice.name, language: voice.lang, localService: voice.localService, default: voice.default, original: voice // Keep reference to original voice })); // Group voices by language this.voicesByLang = {}; this.voices.forEach(voice => { if (voice.language) { const langCode = voice.language.split('-')[0].toLowerCase(); if (!this.voicesByLang[langCode]) { this.voicesByLang[langCode] = []; } this.voicesByLang[langCode].push(voice); } }); return true; }; // If voices are already loaded, process them if (window.speechSynthesis.getVoices().length > 0) { return processVoices(); } // Otherwise, wait for voiceschanged event return new Promise(resolve => { // Set up timeout to handle browsers that don't trigger voiceschanged const timeoutId = setTimeout(() => { if (window.speechSynthesis.getVoices().length > 0) { window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged); resolve(processVoices()); } else { console.warn('Browser TTS: Voices not loaded after timeout'); resolve(false); } }, 1000); this.onVoicesChanged = () => { clearTimeout(timeoutId); window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged); resolve(processVoices()); }; window.speechSynthesis.addEventListener('voiceschanged', this.onVoicesChanged); }); } /** * Set up voice based on preferences and locale * @returns {Promise} - Resolves with success status */ async setupVoiceFromPreferences() { const persistenceManager = this.getModule('persistence-manager'); const localization = this.getModule('localization'); if (!persistenceManager || !localization || this.voices.length === 0) { return false; } // Get preferred voice ID from preferences const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', ''); // Get current locale const currentLocale = localization.getLocale(); // If we have a preferred voice ID, use it if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) { this.voiceOptions.voice = preferredVoiceId; return true; } // Otherwise, select voice based on locale if (currentLocale) { return this.selectVoiceForLocale(currentLocale); } // Fall back to default voice return this.selectDefaultVoice(); } /** * Select a voice for the given locale * @param {string} locale - Locale code * @returns {boolean} - Success status */ selectVoiceForLocale(locale) { if (!locale || this.voices.length === 0) { return this.selectDefaultVoice(); } // Extract language code from locale (e.g., 'en-US' -> 'en') const langCode = locale.split('-')[0].toLowerCase(); // First try to find a voice that exactly matches the locale let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase()); // If not found, try to find a voice for the language if (!matchedVoice && this.voicesByLang[langCode]) { // Prefer default voices if available matchedVoice = this.voicesByLang[langCode].find(v => v.default) || this.voicesByLang[langCode][0]; } if (matchedVoice) { this.voiceOptions.voice = matchedVoice.id; return true; } // Fall back to default voice return this.selectDefaultVoice(); } /** * Select a default voice * @returns {boolean} - Success status */ selectDefaultVoice() { if (this.voices.length === 0) { return false; } // Find a default English voice if available const defaultEnVoice = this.voices.find(v => v.default && v.language && v.language.startsWith('en')); // Otherwise use any default voice const defaultVoice = defaultEnVoice || this.voices.find(v => v.default) || this.voices[0]; this.voiceOptions.voice = defaultVoice.id; return true; } /** * Set up utterance handlers for speech events */ setupUtteranceHandlers() { // Handler functions for utterance events this.utteranceHandlers = { start: () => { this.isSpeaking = true; document.dispatchEvent(new CustomEvent('tts:audio-started', { detail: { provider: this.id || this.name } })); }, end: () => { this.isSpeaking = false; this.currentUtterance = null; }, error: (event) => { console.error('Browser TTS: Speech error:', event); this.isSpeaking = false; this.currentUtterance = null; }, pause: () => { this.isSpeaking = false; }, resume: () => { this.isSpeaking = true; } }; } /** * Handle voice preference changed event * @param {Event} event - Event object */ handleVoicePreferenceChanged(event) { if (event && event.detail) { this.setVoiceOptions(event.detail); } } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Processed text */ preprocessText(text) { if (!text) { return ''; } // Remove HTML tags let processed = text.replace(/<[^>]*>/g, ' '); // Replace special characters processed = processed.replace(/&/g, ' and '); // Normalize whitespace processed = processed.replace(/\s+/g, ' ').trim(); // Add trailing period if missing if (!/[.!?]$/.test(processed)) { processed += '.'; } this.lastPreprocessedText = processed; return processed; } /** * Speak text * @param {string} text - Text to speak * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speak(text, callback = null) { if (!this.isReady || !text) { if (callback) { callback({ success: false, reason: 'not_ready_or_empty_text' }); } return false; } try { // Stop any ongoing speech this.stop(); // Process the text const processedText = this.preprocessText(text); // Create a new utterance const utterance = new SpeechSynthesisUtterance(processedText); // Set voice options if (this.voiceOptions.voice) { const voice = this.voices.find(v => v.id === this.voiceOptions.voice); if (voice && voice.original) { utterance.voice = voice.original; } } utterance.rate = this.voiceOptions.speed || 1.0; utterance.pitch = this.voiceOptions.pitch || 1.0; utterance.volume = this.voiceOptions.volume || 1.0; // Set up event handlers utterance.onstart = this.utteranceHandlers.start; utterance.onend = () => { this.utteranceHandlers.end(); if (callback) { callback({ success: true }); } }; utterance.onerror = (event) => { this.utteranceHandlers.error(event); if (callback) { callback({ success: false, reason: 'synthesis_error', error: event }); } }; utterance.onpause = this.utteranceHandlers.pause; utterance.onresume = this.utteranceHandlers.resume; // Start speaking this.currentUtterance = utterance; speechSynthesis.speak(utterance); return true; } catch (error) { console.error('Browser TTS: Failed to speak:', error); if (callback) { callback({ success: false, reason: 'speak_error', error }); } return false; } } /** * Stop speaking * @returns {boolean} - Success status */ stop() { try { speechSynthesis.cancel(); this.isSpeaking = false; this.currentUtterance = null; return true; } catch (error) { console.error('Browser TTS: Failed to stop speech:', error); return false; } } /** * Pause speaking * @returns {boolean} - Success status */ pause() { try { if (this.isSpeaking) { speechSynthesis.pause(); return true; } return false; } catch (error) { console.error('Browser TTS: Failed to pause speech:', error); return false; } } /** * Resume speaking * @returns {boolean} - Success status */ resume() { try { speechSynthesis.resume(); return true; } catch (error) { console.error('Browser TTS: Failed to resume speech:', error); return false; } } /** * Get available voices * @returns {Array} - Array of voice objects */ getAvailableVoices() { return this.voices; } /** * Set voice options * @param {Object} options - Voice options */ setVoiceOptions(options = {}) { if (options.voice) { this.voiceOptions.voice = options.voice; // Save voice preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'browser_voice', options.voice); } } if (typeof options.speed === 'number') { // Web Speech rate uses 1.0 as normal, matching the app-wide slider. this.voiceOptions.speed = Math.max(0.1, Math.min(10.0, options.speed)); } if (typeof options.pitch === 'number') { this.voiceOptions.pitch = Math.max(0.5, Math.min(2.0, options.pitch)); // Save pitch preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'browser_pitch', options.pitch); } } if (typeof options.volume === 'number') { this.voiceOptions.volume = Math.max(0, Math.min(1.0, options.volume)); } } /** * Preload speech for later playback * Not applicable for the browser TTS (always returns null) * @param {string} text - Text to preload * @returns {Promise} - Promise that resolves to null */ async preloadSpeech(text) { // Browser TTS can't preload speech return { success: false, reason: 'not_supported' }; } /** * Speak preloaded speech * Not applicable for the browser TTS (always returns false) * @param {Object} preloadData - Preloaded speech data * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status (always false) */ speakPreloaded(preloadData, callback = null) { if (callback) { callback({ success: false, reason: 'not_supported' }); } return false; } } const browserTTSModule = new BrowserTTSModule(); export { browserTTSModule };