/** * TTS Factory for AI Interactive Fiction * Manages different TTS implementations with a common interface */ class TTSFactory { constructor() { this.ttsHandler = null; this.handlers = {}; this.initializationAttempted = false; this.initializationPromise = null; this.ttsEnabled = true; this.progressCallback = null; this.persistenceManager = null; } /** * Initialize the TTS Factory - Static method for the module loader * @param {Function} reportProgress - Function to report loading progress to the loader * @returns {Promise} - Resolves when TTS is initialized */ static async initializeInterface(reportProgress = null) { console.log('TTS Factory: Initializing interface'); // Create singleton instance if needed if (!window.ttsFactory) { window.ttsFactory = new TTSFactory(); } // Initialize TTS with the progress callback window.ttsFactory.progressCallback = reportProgress; try { // Start initialization process await window.ttsFactory.initialize(); return true; } catch (error) { console.error('Error initializing TTS Factory:', error); return false; } } /** * Initialize the TTS Factory * This will load and initialize all available TTS handlers * @returns {Promise} - Resolves when initialization is complete */ async initialize() { if (this.initializationPromise) { return this.initializationPromise; } this.initializationPromise = new Promise(async (resolve) => { this.initializationAttempted = true; const reportProgress = (percent, message) => { console.log(`TTS progress: ${percent}% - ${message}`); if (this.progressCallback && typeof this.progressCallback === 'function') { this.progressCallback(percent, message); } }; try { // Report starting initialization reportProgress(10, 'Loading TTS modules'); // Get persistence manager if available if (window.PersistenceManager) { this.persistenceManager = window.PersistenceManager; reportProgress(15, 'Persistence manager found, loading preferences'); // Load preferences to determine TTS enabled state and preferred provider const prefs = this.persistenceManager.getAllPreferences(); if (prefs && prefs.tts) { this.ttsEnabled = prefs.tts.enabled; console.log(`TTS Factory: Setting initial TTS enabled state to ${this.ttsEnabled ? 'enabled' : 'disabled'} from preferences`); } } // Import needed modules dynamically const [{ BrowserTTSHandler }, { KokoroHandler }, { ApiTTSHandler }] = await Promise.all([ import('./browser-tts-handler.js'), import('./kokoro-handler.js'), import('./api-tts-handler.js') ]); reportProgress(20, 'TTS modules loaded'); // Create handlers const browserHandler = new BrowserTTSHandler(); const kokoroHandler = new KokoroHandler(); const apiHandler = new ApiTTSHandler(); // Store handlers this.handlers = { browser: browserHandler, kokoro: kokoroHandler, api: apiHandler }; // Get preferred TTS mode from options const preferredTTSMode = this.getPreferredTTSMode(); // Initialize the preferred handler first if (preferredTTSMode === 'browser') { // User prefers browser TTS await this.initializeBrowserTTS(browserHandler, reportProgress); } else if (preferredTTSMode === 'api') { // User prefers API TTS await this.initializeApiTTS(apiHandler, reportProgress); // Fallback to browser TTS if API fails if (!apiHandler.isAvailable()) { await this.initializeBrowserTTS(browserHandler, reportProgress); } } else { // Default flow: prefer Kokoro, with browser as immediate fallback // Initialize browser TTS immediately for a responsive experience await this.initializeBrowserTTS(browserHandler, reportProgress); // Then schedule Kokoro loading in the background reportProgress(75, 'Scheduling Kokoro TTS initialization'); this.scheduleKokoroInitialization(kokoroHandler, reportProgress).then((kokoroAvailable) => { if (kokoroAvailable) { // Switch to Kokoro as it's the best option and set as preferred this.ttsHandler = kokoroHandler; this.setPreferredTTSMode('kokoro'); this.dispatchTTSReadyEvent(true, 'kokoro', kokoroHandler); reportProgress(100, 'Kokoro TTS ready'); // Apply voice settings from preferences if available this.applyVoiceSettingsFromPreferences(); } else if (!this.getPreferredTTSMode()) { // If Kokoro failed and no preference was previously set, // set browser as preferred mode this.setPreferredTTSMode('browser'); } }); } // Apply voice settings from preferences for initial handler this.applyVoiceSettingsFromPreferences(); // Resolve initialization even though Kokoro is still loading in background reportProgress(80, 'TTS interface ready' + (preferredTTSMode !== 'kokoro' ? '' : ' (Kokoro loading in background)')); resolve(true); } catch (error) { console.error('Error initializing TTS Factory:', error); // If we have any handler working, consider initialization successful if (this.ttsHandler) { reportProgress(100, `Using ${this.ttsHandler.getId()} TTS (fallback)`); resolve(true); } else { this.dispatchTTSReadyEvent(false); reportProgress(100, 'TTS initialization failed'); resolve(false); } } }); return this.initializationPromise; } /** * Apply stored voice settings from preferences * @private */ applyVoiceSettingsFromPreferences() { if (!this.ttsHandler || !this.persistenceManager) return; const prefs = this.persistenceManager.getAllPreferences(); if (prefs && prefs.tts) { if (prefs.tts.voice) { console.log(`TTS Factory: Setting voice to ${prefs.tts.voice} from preferences`); // Check if setVoice exists, otherwise try setting through voiceOptions if (typeof this.ttsHandler.setVoice === 'function') { this.ttsHandler.setVoice(prefs.tts.voice); } else if (typeof this.ttsHandler.setVoiceOptions === 'function') { this.ttsHandler.setVoiceOptions({ voice: prefs.tts.voice }); } } if (prefs.tts.rate !== undefined) { console.log(`TTS Factory: Setting speech rate to ${prefs.tts.rate} from preferences`); // Check if setSpeed exists, otherwise try setting through voiceOptions if (typeof this.ttsHandler.setSpeed === 'function') { this.ttsHandler.setSpeed(prefs.tts.rate); } else if (typeof this.ttsHandler.setVoiceOptions === 'function') { this.ttsHandler.setVoiceOptions({ rate: prefs.tts.rate }); } } if (prefs.tts.volume !== undefined && typeof this.ttsHandler.setVolume === 'function') { console.log(`TTS Factory: Setting volume to ${prefs.tts.volume} from preferences`); this.ttsHandler.setVolume(prefs.tts.volume); } } } /** * Initialize browser TTS * @param {BrowserTTSHandler} handler - The browser TTS handler * @param {Function} reportProgress - Progress reporting function * @returns {Promise} - Resolves with availability status */ async initializeBrowserTTS(handler, reportProgress) { reportProgress(30, 'Initializing browser TTS'); const browserAvailable = await handler.initialize(); if (browserAvailable) { this.ttsHandler = handler; this.dispatchTTSReadyEvent(true, 'browser', handler); reportProgress(40, 'Browser TTS ready'); } else { reportProgress(40, 'Browser TTS not available'); } return browserAvailable; } /** * Initialize API TTS * @param {ApiTTSHandler} handler - The API TTS handler * @param {Function} reportProgress - Progress reporting function * @returns {Promise} - Resolves with availability status */ async initializeApiTTS(handler, reportProgress) { reportProgress(50, 'Initializing API TTS'); const apiAvailable = await handler.initialize(); if (apiAvailable) { this.ttsHandler = handler; this.dispatchTTSReadyEvent(true, 'api', handler); reportProgress(70, 'API TTS ready'); } return apiAvailable; } /** * Get preferred TTS mode from storage * @returns {string|null} - Preferred TTS mode or null if not set */ getPreferredTTSMode() { // First check persistent settings if available if (this.persistenceManager) { const prefs = this.persistenceManager.getAllPreferences(); if (prefs && prefs.tts && prefs.tts.provider) { console.log(`TTS Factory: Using preferred TTS mode '${prefs.tts.provider}' from persistence manager`); return prefs.tts.provider; } } // Fallback to localStorage if persistence manager is not available try { const savedMode = localStorage.getItem('preferred-tts-mode'); if (savedMode) { console.log(`TTS Factory: Using preferred TTS mode '${savedMode}' from localStorage`); return savedMode; } } catch (e) { console.warn('Could not read TTS preference from localStorage'); } // Default to Kokoro if no preference is found return "kokoro"; } /** * Set preferred TTS mode in storage * @param {string} mode - The TTS mode to save as preferred */ setPreferredTTSMode(mode) { // Update in persistence manager if available if (this.persistenceManager) { this.persistenceManager.updatePreference('tts', 'provider', mode); console.log(`TTS Factory: Saved preferred TTS mode '${mode}' to persistence manager`); } // Also save to localStorage as backup try { localStorage.setItem('preferred-tts-mode', mode); } catch (e) { console.warn('Could not save TTS preference to localStorage'); } } /** * Schedule Kokoro initialization during idle time * @param {Object} kokoroHandler - The Kokoro handler instance * @param {Function} reportProgress - Progress reporting function * @returns {Promise} - Resolves with success status */ scheduleKokoroInitialization(kokoroHandler, reportProgress) { // Immediately dispatch the loading started event so tts-player can catch it window.dispatchEvent(new CustomEvent('kokoro-loading-started')); return new Promise((resolve) => { // Create the initialization function const startKokoroInit = async () => { try { // Initialize Kokoro with progress callback const kokoroAvailable = await kokoroHandler.initialize((percent, message) => { // Scale progress to 80-95% range for the TTS module's overall progress const scaledProgress = 80 + Math.floor(percent * 0.15); reportProgress(scaledProgress, message || `Loading Kokoro TTS: ${percent}%`); }); // Mark completion if (kokoroAvailable) { reportProgress(95, "Kokoro TTS initialized successfully"); } else { reportProgress(95, "Kokoro TTS unavailable - using fallback"); } // Always dispatch event to indicate completion status window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { detail: { success: kokoroAvailable } })); resolve(kokoroAvailable); } catch (error) { console.error('Error initializing Kokoro:', error); reportProgress(95, 'Kokoro TTS failed to initialize - using fallback'); // Dispatch completion event with error information window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { detail: { success: false, error: error.message } })); resolve(false); } }; // Add timeout protection with a reasonable timeout (30 seconds for resource-intensive operations) const timeoutId = setTimeout(() => { reportProgress(95, 'Kokoro initialization timed out - using fallback'); window.dispatchEvent(new CustomEvent('kokoro-loading-complete', { detail: { success: false, error: "Timeout" } })); resolve(false); }, 30000); // Increased timeout to 30 seconds since model loading is resource intensive // Use requestIdleCallback to start initialization during idle time if (window.requestIdleCallback) { reportProgress(75, 'Scheduling Kokoro TTS for background loading'); window.requestIdleCallback(() => { startKokoroInit().then(() => clearTimeout(timeoutId)); }, { timeout: 10000 }); } else { reportProgress(75, 'Background loading not available, loading Kokoro normally'); // Use a microtask to avoid blocking the UI thread Promise.resolve().then(() => startKokoroInit().then(() => clearTimeout(timeoutId))); } }); } /** * Dispatch a custom event when TTS is ready * @param {boolean} available - Whether TTS is available * @param {string} type - The type of TTS * @param {Object} handler - The TTS handler object */ dispatchTTSReadyEvent(available, type = null, handler = null) { const event = new CustomEvent('tts-ready', { detail: { available, type, handler, enabled: this.ttsEnabled } }); window.dispatchEvent(event); } /** * Get information about the active TTS system * @returns {Object} - TTS system info */ getActiveTTSInfo() { if (!this.ttsHandler) { return { available: false, type: 'none', name: 'None' }; } const id = this.ttsHandler.getId(); const name = { 'browser': 'Browser TTS', 'kokoro': 'Kokoro Neural TTS', 'api': 'ElevenLabs API TTS' }[id] || 'Unknown TTS'; return { available: true, type: id, name: name }; } /** * Switch to a specific TTS handler * @param {string} type - The handler ID to use * @returns {boolean} - Success status */ switchTTS(type) { if (!this.handlers[type] || !this.handlers[type].isAvailable()) { return false; } this.ttsHandler = this.handlers[type]; this.dispatchTTSReadyEvent(true, type, this.ttsHandler); // Update preferred TTS mode this.setPreferredTTSMode(type); return true; } /** * Speak text using the active TTS handler * @param {string} text - Text to speak * @param {Function} callback - Called when speech completes * @returns {boolean} - True if speech started successfully */ speak(text, callback = null) { if (!this.ttsEnabled || !this.ttsHandler) { console.warn("TTSFactory: No active TTS handler available or TTS disabled"); if (callback) callback("No TTS handler"); return false; } const handlerType = this.ttsHandler.getId(); console.log(`TTSFactory: Using ${handlerType} handler to speak "${text}"`); try { this.ttsHandler.speak(text, (result) => { console.log(`TTSFactory: Speech completed using ${handlerType}`, result); if (callback) callback(result); }); return true; } catch (error) { console.error('Error speaking:', error); if (callback) callback(error); return false; } } /** * Stop any ongoing speech */ stop() { if (this.ttsHandler) { this.ttsHandler.stop(); } } /** * Set voice options for the active handler * @param {Object} options - Voice options */ setVoiceOptions(options = {}) { if (this.ttsHandler && typeof this.ttsHandler.setVoiceOptions === 'function') { this.ttsHandler.setVoiceOptions(options); // Save settings to persistence manager if available if (this.persistenceManager) { if (options.voice !== undefined) { this.persistenceManager.updatePreference('tts', 'voice', options.voice, false); } if (options.rate !== undefined) { this.persistenceManager.updatePreference('tts', 'rate', options.rate, false); } if (options.volume !== undefined) { this.persistenceManager.updatePreference('tts', 'volume', options.volume, false); } // Save all changes at once this.persistenceManager.savePreferences(); } } } /** * Toggle TTS on/off * @returns {boolean} - New TTS enabled state */ toggle() { this.ttsEnabled = !this.ttsEnabled; console.log(`TTS Factory: Toggling TTS to ${this.ttsEnabled ? 'enabled' : 'disabled'}`); if (!this.ttsEnabled && this.ttsHandler) { this.ttsHandler.stop(); } // Save the new state to preferences if persistence manager is available if (this.persistenceManager) { this.persistenceManager.updatePreference('tts', 'enabled', this.ttsEnabled); console.log(`TTS Factory: Saved enabled state (${this.ttsEnabled}) to persistence manager`); } return this.ttsEnabled; } /** * Check if TTS is enabled * @returns {boolean} - Current TTS enabled state */ isEnabled() { return this.ttsEnabled; } /** * Get available handlers * @returns {Object} - Map of available handlers */ getAvailableHandlers() { const available = {}; Object.entries(this.handlers).forEach(([id, handler]) => { if (handler.isAvailable()) { available[id] = handler; } }); return available; } /** * Get available voices from active handler * @returns {Promise} - Array of available voices */ async getVoices() { if (!this.ttsHandler || typeof this.ttsHandler.getVoices !== 'function') { return []; } try { return await this.ttsHandler.getVoices(); } catch (error) { console.error('Error getting voices:', error); return []; } } } // Create singleton instance const ttsFactory = new TTSFactory(); // Export the factory export { ttsFactory }; // Keep global reference window.ttsFactory = ttsFactory;