/** * BrowserTTSHandler for AI Interactive Fiction * Implementation using the browser's Web Speech API */ import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class BrowserTTSHandler extends TTSHandler { constructor() { super(); this.id = 'browser'; this.name = 'Browser TTS Handler'; // 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.voices = []; this.currentUtterance = null; this.preloadCache = new Map(); // Add dependencies this.dependencies = ['localization', 'persistence-manager']; // Bind methods this.bindMethods([ 'initialize', 'speak', 'speakPreloaded', 'preloadSpeech', 'stop', 'isAvailable', 'getId', 'getVoices', 'setVoiceOptions', 'onVoicesChanged', 'getModule' ]); } /** * Get a module from the registry * @param {string} moduleId - ID of the module to get * @returns {Object|null} - The module or null if not found */ getModule(moduleId) { return moduleRegistry.getModule(moduleId); } /** * Initialize the browser TTS handler * @param {Function} progressCallback - Callback for progress updates * @returns {Promise} - Resolves with success status */ async initialize(progressCallback = null) { try { if (progressCallback) { progressCallback(10, "Initializing Browser TTS Handler"); } // Check if the browser supports speech synthesis if (!window.speechSynthesis) { console.error("Browser TTS: Speech synthesis not supported by browser"); if (progressCallback) { progressCallback(100, "Browser TTS unavailable"); } return false; } if (progressCallback) { progressCallback(30, "Loading voices"); } try { // Load available voices await this.loadVoices(); if (progressCallback) { progressCallback(70, "Setting up voice"); } // Get localization module const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); // Get current locale and preferred voice let currentLocale = 'en-us'; let preferredVoice = ''; if (localization) { currentLocale = localization.getLocale(); } else { console.error("Browser TTS: Localization module not found"); } if (persistenceManager) { preferredVoice = persistenceManager.getPreference('tts', 'voice', ''); } else { console.error("Browser TTS: Persistence Manager module not found"); } // Set voice based on locale and preferences await this.selectVoiceForLocale(currentLocale, preferredVoice); // Check if we have a voice set if (this.voiceOptions.voice) { this.available = true; this.isReady = true; if (progressCallback) { progressCallback(100, "Browser TTS Handler ready"); } return true; } else { // Try one more time with a delay console.log("Browser TTS: No voice set, trying again after delay"); if (progressCallback) { progressCallback(80, "Retrying voice loading"); } // Wait a bit and try again return new Promise(resolve => { setTimeout(async () => { await this.loadVoices(); await this.selectVoiceForLocale(currentLocale, preferredVoice); if (this.voiceOptions.voice) { this.available = true; this.isReady = true; if (progressCallback) { progressCallback(100, "Browser TTS Handler ready"); } resolve(true); } else { console.error("Browser TTS: Failed to set voice after retry"); if (progressCallback) { progressCallback(100, "Browser TTS initialization failed"); } resolve(false); } }, 1000); }); } } catch (error) { console.error("Browser TTS: Error loading voices:", error); if (progressCallback) { progressCallback(100, "Browser TTS initialization failed"); } return false; } } catch (error) { console.error("Browser TTS: Initialization error:", error); if (progressCallback) { progressCallback(100, "Browser TTS initialization failed"); } return false; } } /** * Handle voices changed event */ async onVoicesChanged() { await this.loadVoices(); const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); let currentLocale = 'en-us'; let preferredVoice = ''; if (localization) { currentLocale = localization.getLocale(); } if (persistenceManager) { preferredVoice = persistenceManager.getPreference('tts', 'voice', ''); } await this.selectVoiceForLocale(currentLocale, preferredVoice); } /** * Load available voices * @returns {Promise} */ async loadVoices() { return new Promise(resolve => { // Get available voices const getVoices = () => { this.voices = speechSynthesis.getVoices() || []; console.log(`Browser TTS: Loaded ${this.voices.length} voices`); resolve(); }; // Some browsers need a timeout to get voices const timeoutId = setTimeout(() => { if (this.voices.length === 0) { this.voices = speechSynthesis.getVoices() || []; console.log(`Browser TTS: Loaded ${this.voices.length} voices after timeout`); resolve(); } }, 1000); // Try to get voices immediately this.voices = speechSynthesis.getVoices() || []; if (this.voices.length > 0) { clearTimeout(timeoutId); console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`); resolve(); } else { // If no voices are available yet, set up the onvoiceschanged event speechSynthesis.onvoiceschanged = () => { clearTimeout(timeoutId); this.voices = speechSynthesis.getVoices() || []; console.log(`Browser TTS: Loaded ${this.voices.length} voices from event`); speechSynthesis.onvoiceschanged = null; resolve(); }; } }); } /** * 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} */ async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') { // Normalize locale for comparison const normalizedLocale = locale.toLowerCase().split('-')[0]; // If we have a preferred voice, try to use it first if (preferredVoice) { const matchingVoice = this.voices.find(voice => voice.name === preferredVoice || voice.voiceURI === preferredVoice ); if (matchingVoice) { this.voiceOptions.voice = matchingVoice; console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`); return; } } // Find voices matching the locale const localeVoices = this.voices.filter(voice => { const voiceLocale = voice.lang.toLowerCase(); return voiceLocale.startsWith(normalizedLocale) || voice.name.toLowerCase().includes(normalizedLocale); }); if (localeVoices.length > 0) { // Use the first matching voice this.voiceOptions.voice = localeVoices[0]; console.log(`Browser TTS: Using ${normalizedLocale} voice: ${this.voiceOptions.voice.name}`); return; } // If no matching voice found, try to find any voice if (this.voices.length > 0) { // Look for a preferred language voice (English) const englishVoices = this.voices.filter(voice => voice.lang.toLowerCase().startsWith('en') ); if (englishVoices.length > 0) { this.voiceOptions.voice = englishVoices[0]; console.log(`Browser TTS: No ${normalizedLocale} voice found, using English voice: ${this.voiceOptions.voice.name}`); } else { // Use the first available voice this.voiceOptions.voice = this.voices[0]; console.log(`Browser TTS: No ${normalizedLocale} or English voice found, using: ${this.voiceOptions.voice.name}`); } } else { console.log("Browser TTS: No voices available"); } } /** * Preload speech for a text * @param {string} text - Text to preload * @returns {Promise} - Preloaded speech data */ async preloadSpeech(text) { if (!this.available || !text || !this.voiceOptions.voice) { return null; } try { // Process text for TTS const processedText = this.preprocessText(text); console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`); // Create utterance but don't speak it yet const utterance = new SpeechSynthesisUtterance(processedText); // Set voice and options utterance.voice = this.voiceOptions.voice; utterance.rate = this.voiceOptions.rate; utterance.pitch = this.voiceOptions.pitch; utterance.volume = this.voiceOptions.volume; utterance.lang = this.voiceOptions.voice.lang; // Store preloaded data const preloadData = { utterance, text: processedText }; this.preloadCache.set(text, preloadData); return preloadData; } catch (error) { console.warn("Browser TTS: Error preloading speech:", error); return null; } } /** * Speak text using preloaded utterance * @param {Object} preloadData - Preloaded speech data * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speakPreloaded(preloadData, callback = null) { if (!this.available || !preloadData || !preloadData.utterance) { if (callback) { setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0); } return false; } try { // Stop any current speech this.stop(); const { utterance, text } = preloadData; // Dispatch start event this.dispatchEvent('tts:speak:start', { text }); // Set up event listeners utterance.onend = () => { this.currentUtterance = null; // Dispatch end event this.dispatchEvent('tts:speak:end', { text }); if (callback) { callback({ success: true }); } }; utterance.onerror = (error) => { this.currentUtterance = null; // Dispatch error event this.dispatchEvent('tts:speak:error', { text, error: error.error || 'Unknown error' }); if (callback) { callback({ success: false, reason: 'synthesis_error', error }); } }; // Store reference to current utterance this.currentUtterance = utterance; // Speak the utterance speechSynthesis.speak(utterance); return true; } catch (error) { console.error("Browser TTS: Error playing preloaded speech:", error); // Dispatch error event this.dispatchEvent('tts:speak:error', { text: preloadData.text, error: error.message || 'Unknown error' }); if (callback) { setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0); } 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.available || !this.voiceOptions.voice) { if (callback) { setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); } return false; } try { // Stop any current speech this.stop(); // Check if we have this in the preload cache if (this.preloadCache.has(text)) { const preloadData = this.preloadCache.get(text); this.preloadCache.delete(text); // Remove from cache return this.speakPreloaded(preloadData, callback); } // Process text for TTS const processedText = this.preprocessText(text); // Create utterance const utterance = new SpeechSynthesisUtterance(processedText); // Set voice and options utterance.voice = this.voiceOptions.voice; utterance.rate = this.voiceOptions.rate; utterance.pitch = this.voiceOptions.pitch; utterance.volume = this.voiceOptions.volume; utterance.lang = this.voiceOptions.voice.lang; // Dispatch start event this.dispatchEvent('tts:speak:start', { text: processedText }); // Set up event listeners utterance.onend = () => { this.currentUtterance = null; // Dispatch end event this.dispatchEvent('tts:speak:end', { text: processedText }); if (callback) { callback({ success: true }); } }; utterance.onerror = (error) => { this.currentUtterance = null; // Dispatch error event this.dispatchEvent('tts:speak:error', { text: processedText, error: error.error || 'Unknown error' }); if (callback) { callback({ success: false, reason: 'synthesis_error', error }); } }; // Store reference to current utterance this.currentUtterance = utterance; // Speak the utterance speechSynthesis.speak(utterance); return true; } catch (error) { console.error("Browser TTS: Error generating speech:", error); // Dispatch error event this.dispatchEvent('tts:speak:error', { text, error: error.message || 'Unknown error' }); if (callback) { setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0); } return false; } } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Processed text */ preprocessText(text) { if (!text) return ''; // Trim whitespace let processed = text.trim(); // Replace multiple spaces with a single space processed = processed.replace(/\s+/g, ' '); // Add a period at the end if there's no punctuation if (!/[.!?]$/.test(processed)) { processed += '.'; } return processed; } /** * Stop speaking */ stop() { if (speechSynthesis) { speechSynthesis.cancel(); this.currentUtterance = null; } } /** * Check if TTS is available * @returns {boolean} - True if TTS is available */ isAvailable() { return this.available && this.voiceOptions.voice !== null; } /** * Get handler ID * @returns {string} - Handler ID */ getId() { return this.id; } /** * Get available voices * @returns {Array} - Array of voice objects */ getVoices() { return this.voices.map(voice => ({ id: voice.voiceURI, name: voice.name, language: voice.lang })); } /** * Set voice options * @param {Object} options - Voice options */ setVoiceOptions(options = {}) { if (options.voice) { // Find the voice by ID or name const voice = this.voices.find(v => v.voiceURI === options.voice || v.name === options.voice ); if (voice) { this.voiceOptions.voice = voice; } } if (typeof options.rate === 'number') { // Clamp rate between 0.1 and 10 this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate)); } if (typeof options.pitch === 'number') { // Clamp pitch between 0 and 2 this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch)); } if (typeof options.volume === 'number') { // Clamp volume between 0 and 1 this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume)); } } }