/** * BrowserTTSModule for AI Interactive Fiction * Implementation using the browser's Web Speech API */ import { TTSHandlerModule } from './tts-handler-module.js'; /** * Browser TTS Module - Uses the browser's Web Speech API for TTS */ export class BrowserTTSModule extends TTSHandlerModule { constructor() { super('browser', 'Browser TTS'); // Voice options this.voiceOptions = { voice: null, // Will be set during initialization rate: 1.0, pitch: 1.0, volume: 1.0 }; // State this.available = false; this.currentUtterance = null; // Ensure dependencies are correctly defined from parent class // this.dependencies should already contain ['persistence-manager', 'localization'] // Bind additional methods beyond those in TTSHandlerModule this.bindMethods([ 'onVoicesChanged', 'loadVoices', 'selectVoiceForLocale', 'synthesizeToWav', 'speakPreloaded', 'speak', 'preprocessText', 'inferVoiceGender' ]); } /** * Initialize the browser TTS module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, 'Initializing Browser TTS'); // Check for browser support if (!window.speechSynthesis) { console.error('Browser TTS: Speech synthesis not available in this browser'); return false; } this.reportProgress(30, 'Browser TTS supported'); // Initialize parent const parentInit = await super.initialize(); if (!parentInit) { console.error('Browser TTS: Parent initialization failed'); return false; } // Get required dependencies const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.error('Browser TTS: Required dependency persistence-manager not found'); return false; } const localization = this.getModule('localization'); if (!localization) { console.error('Browser TTS: Required dependency localization not found'); return false; } // Load voices const voicesLoaded = await this.loadVoices(); if (!voicesLoaded) { console.error('Browser TTS: Failed to load voices'); return false; } // Set speech options from preferences this.voiceOptions.rate = persistenceManager.getPreference('tts', 'rate', 1.0); this.voiceOptions.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0); this.voiceOptions.volume = persistenceManager.getPreference('tts', 'volume', 1.0); const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); // Set voice based on current locale const currentLocale = localization.getLocale() || 'en-us'; await this.selectVoiceForLocale(currentLocale, preferredVoice); // Listen for locale changes document.addEventListener('locale:changed', async (event) => { if (event.detail && event.detail.locale) { await this.selectVoiceForLocale(event.detail.locale); } }); // Listen for voices changed events if (window.speechSynthesis.onvoiceschanged !== undefined) { window.speechSynthesis.onvoiceschanged = this.onVoicesChanged; } this.isReady = true; this.available = true; this.reportProgress(100, 'Browser TTS initialized'); return true; } catch (error) { console.error('Browser TTS: Initialization error:', error); this.isReady = false; this.available = false; return false; } } /** * Handle voices changed event */ async onVoicesChanged() { await this.loadVoices(); // Re-select voice based on current locale const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); if (localization && persistenceManager) { const currentLocale = localization.getLocale() || 'en-us'; const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); await this.selectVoiceForLocale(currentLocale, preferredVoice); } } /** * Load available voices from the speech synthesis API * @returns {Promise} - Resolves with success status */ async loadVoices() { try { this.reportProgress(40, 'Loading browser voices'); // Try to get voices let voices = window.speechSynthesis.getVoices(); // If voices array is empty, wait for onvoiceschanged event if (!voices || voices.length === 0) { try { console.log('Browser TTS: No voices available immediately, waiting for voices to load...'); // Wait for voices to be loaded (with timeout) voices = await new Promise((resolve, reject) => { // Set a timeout in case voices never load const timeout = setTimeout(() => { console.warn('Browser TTS: Timeout waiting for voices'); // Resolve with empty array instead of rejecting resolve([]); }, 3000); // Listen for voices changed event window.speechSynthesis.onvoiceschanged = () => { clearTimeout(timeout); const loadedVoices = window.speechSynthesis.getVoices(); console.log(`Browser TTS: Voices loaded, found ${loadedVoices.length} voices`); resolve(loadedVoices); }; }); } catch (voiceWaitError) { console.error('Browser TTS: Error waiting for voices:', voiceWaitError); // Continue with empty voices array voices = []; } } // Store voices this.voices = voices || []; // Log available voices for debugging console.log(`Browser TTS: Loaded ${this.voices.length} voices`); if (this.voices.length > 0) { console.log('Browser TTS: First few voices:', this.voices.slice(0, 3)); } // If no voices available but speech synthesis is supported, still return true // Some browsers may not expose voices but still support speech synthesis if (this.voices.length === 0) { console.warn('Browser TTS: No voices available, but continuing with default voice'); // Create a default voice entry this.voices = [{ default: true, lang: 'en-US', localService: true, name: 'Default Voice', voiceURI: 'default' }]; } this.reportProgress(60, 'Browser voices loaded'); return true; } catch (error) { console.error('Browser TTS: Error loading voices:', error); return false; } } /** * Set voice based on locale * @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr') * @param {string} preferredVoice - Optional preferred voice name * @returns {Promise} - Success status */ async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') { // Normalize locale format locale = locale.toLowerCase().replace('_', '-'); const languageCode = locale.split('-')[0]; // First try to use the preferred voice if specified if (preferredVoice) { const voice = this.voices.find(v => v.name === preferredVoice || v.voiceURI === preferredVoice ); if (voice) { this.voiceOptions.voice = voice; return true; } } // Try to find a voice that matches the exact locale const exactMatch = this.voices.find(v => v.lang.toLowerCase() === locale ); if (exactMatch) { this.voiceOptions.voice = exactMatch; return true; } // Try to find a voice that matches the language code const languageMatch = this.voices.find(v => v.lang.toLowerCase().startsWith(languageCode) ); if (languageMatch) { this.voiceOptions.voice = languageMatch; return true; } // Fallback to the first available voice if (this.voices.length > 0) { this.voiceOptions.voice = this.voices[0]; return true; } // No voices available return false; } /** * 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 || !window.speechSynthesis) { if (callback) { callback({ success: false, reason: 'not_ready' }); } return false; } // Stop any ongoing speech this.stop(); const processedText = this.preprocessText(text); // Create utterance const utterance = new SpeechSynthesisUtterance(processedText); // Set options if (this.voiceOptions.voice) { utterance.voice = this.voiceOptions.voice; } utterance.rate = this.voiceOptions.rate; utterance.pitch = this.voiceOptions.pitch; utterance.volume = this.voiceOptions.volume; // Set up event handlers utterance.onend = () => { this.isSpeaking = false; if (callback) { callback({ success: true }); } }; utterance.onerror = (error) => { this.isSpeaking = false; console.error('Browser TTS: Speech error', error); if (callback) { callback({ success: false, reason: 'synthesis_error', error }); } }; // Store current utterance this.currentUtterance = utterance; this.isSpeaking = true; // Start speaking window.speechSynthesis.speak(utterance); return true; } /** * Preload speech for a text * @param {string} text - Text to preload * @returns {Promise} - Preloaded speech data */ async preloadSpeech(text) { if (!this.isReady || !window.speechSynthesis) { return { success: false, reason: 'not_ready' }; } // Generate WAV audio data const wavResult = await this.synthesizeToWav(text); if (!wavResult.success) { return { success: false, reason: 'synthesis_failed' }; } return { success: true, audioData: wavResult.audioData, text, duration: wavResult.duration || 0 }; } /** * Convert speech synthesis to WAV format * @param {string} text - Text to synthesize * @returns {Promise} - Object with audio data */ async synthesizeToWav(text) { return new Promise((resolve) => { if (!this.isReady || !window.speechSynthesis) { resolve({ success: false, reason: 'not_ready' }); return; } // Process text for better synthesis const processedText = this.preprocessText(text); // Create audio context const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) { resolve({ success: false, reason: 'no_audio_context' }); return; } const audioContext = new AudioContext(); // Create media stream destination const destination = audioContext.createMediaStreamDestination(); // Create media recorder const mediaRecorder = new MediaRecorder(destination.stream); const audioChunks = []; // Set up event handlers mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); } }; mediaRecorder.onstop = () => { // Create blob from chunks const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); // Convert blob to array buffer const reader = new FileReader(); reader.onloadend = () => { resolve({ success: true, audioData: reader.result }); }; reader.onerror = () => { resolve({ success: false, reason: 'blob_read_error' }); }; reader.readAsArrayBuffer(audioBlob); }; // Create utterance const utterance = new SpeechSynthesisUtterance(processedText); // Set options if (this.voiceOptions.voice) { utterance.voice = this.voiceOptions.voice; } utterance.rate = this.voiceOptions.rate; utterance.pitch = this.voiceOptions.pitch; utterance.volume = this.voiceOptions.volume; // Start recording mediaRecorder.start(); // Set up completion handling utterance.onend = () => { mediaRecorder.stop(); }; utterance.onerror = (error) => { console.error('Browser TTS: Synthesis error', error); mediaRecorder.stop(); resolve({ success: false, reason: 'synthesis_error' }); }; // Start speaking window.speechSynthesis.speak(utterance); // Set timeout in case onend never fires setTimeout(() => { if (mediaRecorder.state === 'recording') { mediaRecorder.stop(); } }, 30000); // 30-second timeout }); } /** * Speak preloaded audio data * @param {Object} preloadedData - Data from preloadSpeech * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speakPreloaded(preloadedData, callback = null) { if (!preloadedData || !preloadedData.text) { console.error('Browser TTS: Invalid preloaded data'); return false; } // For browser TTS, we don't use the preloaded data directly // Instead, we just speak the text again return this.speak(preloadedData.text, callback); } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Processed text */ preprocessText(text) { // Remove HTML tags text = text.replace(/<[^>]*>/g, ' '); // Replace special characters with their spoken equivalents text = text.replace(/&/g, ' and '); // Normalize whitespace text = text.replace(/\s+/g, ' ').trim(); return text; } /** * Stop speaking * @returns {boolean} - Success status */ stop() { if (window.speechSynthesis) { window.speechSynthesis.cancel(); this.isSpeaking = false; this.currentUtterance = null; return true; } return false; } /** * Get available voices * @returns {Array} - Array of voice objects */ async getVoices() { if (!this.isReady) { return []; } const localization = this.getModule('localization'); const currentLocale = localization ? localization.getLocale() : 'en-us'; // Normalize locale format const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); const languageCode = normalizedLocale.split('-')[0]; // Filter voices by current locale const filteredVoices = this.voices.filter(voice => { const voiceLang = voice.lang.toLowerCase(); return voiceLang.startsWith(languageCode) || voiceLang === normalizedLocale || (normalizedLocale.startsWith(voiceLang) && voiceLang.length === 2); }); // If matching voices found, use them if (filteredVoices.length > 0) { return filteredVoices.map(voice => ({ id: voice.voiceURI, name: voice.name, lang: voice.lang, gender: this.inferVoiceGender(voice.name) })); } // If no matching voices found, return all voices return this.voices.map(voice => ({ id: voice.voiceURI, name: voice.name, lang: voice.lang, gender: this.inferVoiceGender(voice.name) })); } /** * Infer voice gender from name * @param {string} name - Voice name * @returns {string} - Inferred gender ('male', 'female', or 'unknown') */ inferVoiceGender(name) { const lowerName = name.toLowerCase(); // Common terms indicating gender const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir']; const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss']; // Check for explicit gender terms in the name for (const term of maleTerms) { if (lowerName.includes(term)) return 'male'; } for (const term of femaleTerms) { if (lowerName.includes(term)) return 'female'; } return 'unknown'; } } // Register the module with the module registry // Module registry MUST be accessed via window, not direct import if (window.moduleRegistry) { try { // Create instance first, then register it const browserTTSModule = new BrowserTTSModule(); window.moduleRegistry.register(browserTTSModule); console.log('Browser TTS Module registered successfully'); } catch (err) { console.error('Failed to register Browser TTS Module:', err); } } else { console.error('Module registry not available when attempting to register Browser TTS Module'); }