/** * TTS Factory Module * Manages TTS handler instances and coordinates TTS functionality */ import { BaseModule } from './base-module.js'; class TTSFactoryModule extends BaseModule { /** * Create a new TTS factory */ constructor() { super('tts-factory', 'TTS Factory'); this.dependencies = [ 'persistence-manager', 'localization', 'browser-tts', // Browser TTS handler 'kokoro-tts', // Kokoro TTS handler 'elevenlabs-tts',// ElevenLabs TTS handler 'openai-tts' // OpenAI TTS handler ]; this.handlers = {}; this.initStatus = {}; this.activeHandler = null; this.ttsAvailable = false; this.speed = 1; // Default speed // IndexedDB Cache Configuration this.db = null; // Will hold the DB connection this.dbName = 'ttsAudioCacheDB'; this.storeName = 'audioCacheStore'; this.dbVersion = 1; this.currentCacheSize = 0; // Track current size in bytes this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default this.cacheInitialized = false; this.cacheStatus = 'uninitialized'; // uninitialized, initializing, ready, error // Bind methods to this instance this.bindMethods([ 'initialize', 'registerHandler', 'initializeHandler', 'setActiveHandler', 'getHandler', 'getActiveHandler', 'getAvailableHandlers', 'speak', 'stop', 'pause', 'resume', 'getVoices', 'getPreference', 'isSpeaking', 'configure', 'preloadSpeech', 'generateSpeechHash', 'speakPreloaded', 'getCachedSpeech', 'manageCacheSize', 'cacheSpeech', 'isSpeechCached', '_initializeDB', '_getDBItem', '_putDBItem', '_deleteDBItem', '_calculateTotalCacheSize', '_getAllDBItemsSortedByAccess', '_getDBItemOnly', '_generateHash', 'initiatePreferredHandler', 'attemptFallbackHandler', 'initializeCache', 'setupEvents', 'reportProgress', 'loadPreferences', 'registerHandlers', 'initializeHandlerSystem', 'debugLogAllRegisteredModules', 'debugTTSHandlers' // Added method ]); // Listen for kokoro:ready event document.addEventListener('kokoro:ready', (event) => { if (event.detail && typeof event.detail.success === 'boolean') { console.log('TTS Factory: Received kokoro:ready event with success =', event.detail.success); this.initStatus['kokoro'] = event.detail.success; // If this is the current active handler or we don't have an active handler yet, // try to activate Kokoro if it's now ready if ((this.activeHandler === 'kokoro' || !this.activeHandler) && event.detail.success) { // Only attempt to set active handler if TTS is enabled const ttsEnabled = this.getPreference('tts', 'enabled', false); if (ttsEnabled) { this.setActiveHandler('kokoro'); } } // Update overall TTS availability this.updateTTSAvailability(); } }); // Listen for handler availability changes document.addEventListener('tts:handler:availabilityChanged', (event) => { if (event && event.detail) { const { handlerId, available } = event.detail; console.log(`TTS Factory: Handler ${handlerId} availability changed to ${available}`); this.updateTTSAvailability(); } }); } /** * Initialize the TTS factory module * @returns {Promise} - Promise resolves with initialization success */ async initialize() { try { console.log('TTS Factory: Initializing'); // Initialize cache this.reportProgress(20, 'Initializing TTS cache'); const cacheInitialized = await this.initializeCache(); if (cacheInitialized) { console.log('TTS Factory: Cache initialized successfully'); } else { console.warn('TTS Factory: Cache initialization failed, continuing without cache'); } // Load preferences this.reportProgress(40, 'Loading TTS preferences'); const preferences = await this.loadPreferences(); // Check for TTS handlers this.reportProgress(60, 'Finding TTS handlers'); await this.registerHandlers(); // Set default status this.ttsAvailable = false; // Set up event handlers - do this before initializing handlers // so we can listen for events during initialization this.setupEvents(); // Initialize preferred or fallback handler this.reportProgress(80, 'Initializing TTS handler'); await this.initializeHandlerSystem(); // Apply configuration from preferences if (preferences) { console.log('TTS Factory: Applying saved configuration'); // Apply speed setting this.configure({ speed: preferences.speed }); // Update TTS availability based on active handler this.updateTTSAvailability(); } // Debug: Log all registered modules and handlers this.debugLogAllRegisteredModules(); this.debugTTSHandlers(); this.reportProgress(100, 'TTS Factory initialized'); console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`); // To maintain backward compatibility, we always return true // since TTS is now optional and the system should function without it return true; } catch (error) { console.error('TTS Factory: Initialization error:', error); return true; // Still return true for backward compatibility } } /** * Register event handlers for TTS system */ setupEvents() { // Listen for TTS handler state changes document.addEventListener('tts:handler-state-changed', (event) => { if (event.detail && event.detail.handler) { const handlerId = event.detail.handler; const ready = event.detail.ready === true; console.log(`TTS Factory: Handler ${handlerId} reported state change, ready = ${ready}`); // Update handler initialization status if (this.handlers[handlerId]) { this.initStatus[handlerId] = ready; this.updateTTSAvailability(); // If this is our active handler and it's no longer ready, try fallback if (this.activeHandler === handlerId && !ready) { console.warn(`TTS Factory: Active handler ${handlerId} is no longer ready, falling back`); this.attemptFallbackHandler(); } } } }); // Listen for kokoro error events document.addEventListener('kokoro:error', (event) => { console.error('TTS Factory: Received kokoro error event:', event.detail); if (this.handlers['kokoro']) { this.initStatus['kokoro'] = false; this.updateTTSAvailability(); // If kokoro was our active handler, try fallback if (this.activeHandler === 'kokoro') { console.warn('TTS Factory: Kokoro handler failed, falling back'); this.attemptFallbackHandler(); } } }); } /** * Initialize a specific TTS handler * @param {string} id - Handler ID to initialize * @returns {Promise} - Resolves with success status */ async initializeHandler(id) { if (!this.handlers[id]) { console.error(`TTS Factory: Handler ${id} not found, cannot initialize`); return false; } try { console.log(`TTS Factory: Initializing handler ${id}`); const handler = this.handlers[id]; console.log(`TTS Factory: Handler ${id} object:`, handler); // Check if handler has initialize method if (typeof handler.initialize !== 'function') { console.error(`TTS Factory: Handler ${id} does not have an initialize method`); this.initStatus[id] = false; return false; } // Call the handler's initialize method console.log(`TTS Factory: Calling initialize method on ${id} handler`); const result = await handler.initialize(); console.log(`TTS Factory: ${id} initialize() returned:`, result); // Store initialization result this.initStatus[id] = result; // Double-check the handler's isReady flag // Note: some handlers may return true from initialize() before they're fully ready // (e.g., Kokoro continues loading the model asynchronously) if (result && typeof handler.isReady === 'boolean') { console.log(`TTS Factory: Handler ${id} has isReady = ${handler.isReady}`); // If handler is still loading after initialize(), set up a listener if (result === true && handler.isReady === false && handler.state === 'INITIALIZING') { console.log(`TTS Factory: Handler ${id} is still initializing, waiting for completion`); // Wait up to 5 seconds for the handler to become ready or error out const readyTimeout = setTimeout(() => { console.warn(`TTS Factory: Handler ${id} did not become ready in time`); // If the handler didn't explicitly fail, we'll still consider it as potentially available }, 5000); // Check every second if the handler is ready const checkInterval = setInterval(() => { if (handler.isReady === true || handler.state === 'FINISHED') { clearInterval(checkInterval); clearTimeout(readyTimeout); console.log(`TTS Factory: Handler ${id} is now ready`); this.initStatus[id] = true; this.updateTTSAvailability(); } else if (handler.state === 'ERROR') { clearInterval(checkInterval); clearTimeout(readyTimeout); console.warn(`TTS Factory: Handler ${id} failed to initialize (state=ERROR)`); this.initStatus[id] = false; this.updateTTSAvailability(); } }, 1000); } else if (result === true && handler.isReady === false) { // If handler says it's ready but didn't set its own isReady flag, set it console.log(`TTS Factory: Setting ${id} isReady = true based on successful initialize()`); handler.isReady = true; } } console.log(`TTS Factory: Handler ${id} initialized with result: ${result}`); return result; } catch (error) { console.error(`TTS Factory: Error initializing handler ${id}:`, error); console.error(`TTS Factory: Handler ${id} stack trace:`, error.stack); this.initStatus[id] = false; return false; } } /** * Get available TTS handlers * @returns {Array} - Array of handler objects */ getAvailableHandlers() { const availableHandlers = []; // Always include a 'none' option availableHandlers.push({ id: 'none', handler: null, displayName: 'None' }); // Always add all registered handlers to the dropdown, regardless of ready state for (const id in this.handlers) { const handler = this.handlers[id]; availableHandlers.push({ id: handler.getId(), handler: handler, displayName: handler.getName(), isReady: handler.isReady === true }); } // Add placeholder entries for important API handlers that might not be registered yet const apiHandlerIds = ['elevenlabs-tts', 'openai-tts']; for (const id of apiHandlerIds) { // Only add if not already in the list if (!this.handlers[id] && !availableHandlers.some(h => h.id === id)) { console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`); availableHandlers.push({ id: id, handler: null, displayName: id.split('-')[0].charAt(0).toUpperCase() + id.split('-')[0].slice(1), isReady: false }); } } console.log(`TTS Factory: Returning ${availableHandlers.length} handlers for UI (including 'none'):`, availableHandlers.map(h => h.id).join(', ')); return availableHandlers; } /** * Report progress during initialization * @param {number} progress - Progress percentage (0-100) * @param {string} message - Progress message */ reportProgress(progress, message) { // Report progress if (this.progressCallback) { this.progressCallback(progress, message); } } /** * Load TTS preferences from persistence manager */ async loadPreferences() { // Get the persistence manager for preferences const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.warn('TTS Factory: No persistence manager available, using default settings'); return; } // Default settings for first run const defaults = { 'speed': 0.5, // Default speech rate (0-1 range) 'preferred_handler': 'kokoro', // Default to Kokoro TTS 'enabled': false, // TTS disabled by default 'voice': '', // Empty default - will be selected based on handler 'language': 'en-US', // Default language 'volume': 1.0, // Default volume 'elevenlabs_api_key': '', // Empty API key by default 'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL 'openai_api_key': '', // Empty API key by default 'openai_api_url': 'https://api.openai.com/v1' // Default OpenAI API URL }; // Ensure all defaults are set in persistence if they don't exist for (const [key, value] of Object.entries(defaults)) { if (persistenceManager.getPreference('tts', key) === undefined) { console.log(`TTS Factory: Setting default for '${key}': ${value}`); persistenceManager.updatePreference('tts', key, value); } } // Load speech rate preference const savedSpeed = persistenceManager.getPreference('tts', 'speed'); if (typeof savedSpeed === 'number') { this.speed = savedSpeed; console.log(`TTS Factory: Loaded speed preference: ${this.speed}`); } else { this.speed = defaults.speed; console.log(`TTS Factory: Using default speed: ${this.speed}`); } // Load other preferences we need for initialization const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`); // We'll handle the preferred handler in initializeHandlerSystem() // Check if TTS is enabled const ttsEnabled = persistenceManager.getPreference('tts', 'enabled'); console.log(`TTS Factory: TTS enabled: ${ttsEnabled}`); // Return the loaded preferences for convenience return { preferredHandler: preferredHandler || defaults.preferred_handler, enabled: ttsEnabled !== undefined ? ttsEnabled : defaults.enabled, speed: this.speed }; } /** * Register TTS handlers from module registry */ async registerHandlers() { // Access module registry directly through window // This is correct architectural pattern - module registry is NOT a dependency const moduleRegistry = window.moduleRegistry; if (!moduleRegistry) { console.error('TTS Factory: Module registry not available via window.moduleRegistry'); return; } console.log('TTS Factory: Module registry found, scanning for TTS modules...'); console.log('TTS Factory: All modules in registry:', Object.keys(moduleRegistry.modules).join(', ')); // Register handlers (in order of preference) const handlers = [ { id: 'kokoro-tts', displayName: 'Kokoro TTS' }, { id: 'browser-tts', displayName: 'Browser TTS' }, { id: 'elevenlabs-tts', displayName: 'ElevenLabs TTS' }, { id: 'openai-tts', displayName: 'OpenAI TTS' } ]; // Register each handler for (const [index, handler] of handlers.entries()) { try { console.log(`TTS Factory: Attempting to get module '${handler.id}'`); const module = this.getModule(handler.id); if (module) { console.log(`TTS Factory: Successfully got module '${handler.id}'`, module); console.log(`TTS Factory: Module class: ${module.constructor.name}, ID: ${module.id}`); this.registerHandler(handler.id, module); console.log(`TTS Factory: Registered handler ${handler.id}`); } else { console.warn(`TTS Factory: Module ${handler.id} not found in module registry. All registered modules: ${Object.keys(moduleRegistry.modules).join(', ')}`); } } catch (error) { console.error(`TTS Factory: Error registering handler ${handler.id}:`, error); } } } /** * Initialize the preferred or fallback TTS handler */ async initializeHandlerSystem() { // Get the preferred handler from persistence manager const persistenceManager = this.getModule('persistence-manager'); let preferredHandler = null; if (persistenceManager) { preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`); } // Special case for 'none' preference if (preferredHandler === 'none') { console.log('TTS Factory: User has disabled TTS (none selected)'); this.activeHandler = null; this.updateTTSAvailability(); return true; } // If user has a preferred handler, attempt to set it even if not initialized if (preferredHandler) { // Check if handler exists if (this.handlers[preferredHandler]) { console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); // Try to initialize the preferred handler const success = await this.initializeHandler(preferredHandler); // Set as active regardless of initialization result // TTS will be considered disabled if handler exists but isn't ready console.log(`TTS Factory: Setting preferred handler ${preferredHandler} as active (init success: ${success})`); await this.setActiveHandler(preferredHandler); return true; } else { console.log(`TTS Factory: Preferred handler ${preferredHandler} not registered yet, will be set when available`); // We can't set it as active yet since it doesn't exist, but we've stored the preference } } // If we don't have a preferred handler or it's not registered, try fallbacks return this.attemptFallbackHandler(); } /** * Attempt to initialize and set fallback handlers in order * @returns {Promise} - Success status */ async attemptFallbackHandler() { // Fallback order: Kokoro -> Browser -> None const fallbackOrder = ['kokoro', 'browser']; // Try each fallback in order for (const handlerId of fallbackOrder) { if (this.handlers[handlerId]) { console.log(`TTS Factory: Trying fallback handler: ${handlerId}`); // Try to initialize this handler const success = await this.initializeHandler(handlerId); if (success) { console.log(`TTS Factory: Fallback handler ${handlerId} initialized successfully`); return await this.setActiveHandler(handlerId); } else { console.warn(`TTS Factory: Fallback handler ${handlerId} initialization failed`); } } } // If all fallbacks failed, update TTS availability console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable'); this.activeHandler = null; this.updateTTSAvailability(); // TTS is optional, so return true even if no handler is available return true; } /** * Initialize the audio cache system * @returns {Promise} - Success status */ async initializeCache() { console.log('TTS Factory: Initializing cache'); if (this.cacheInitialized) { console.log('TTS Factory: Cache already initialized'); return true; } try { // Initialize IndexedDB for audio cache await this._initializeDB(); // Calculate current cache size await this._calculateTotalCacheSize(); this.cacheInitialized = true; console.log(`TTS Factory: Cache initialized, current size: ${this.currentCacheSize} bytes`); return true; } catch (error) { console.error('TTS Factory: Failed to initialize cache:', error); // Cache is non-essential, continue without it this.cacheInitialized = false; return false; } } /** * Register a TTS handler * @param {string} id - Handler ID * @param {Object} handler - TTS handler instance */ registerHandler(id, handler) { if (!handler) { console.error(`TTS Factory: Cannot register null handler for ${id}`); return; } console.log(`TTS Factory: Registering handler ${id}`); this.handlers[id] = handler; // Note: Handlers now declare their own dependencies and access them via getModule() // They no longer need dependencies to be provided by the factory } /** * Set the active TTS handler * @param {string} id - Handler ID * @returns {boolean} - Success status */ async setActiveHandler(id) { // Special case for 'none' option if (id === 'none') { console.log('TTS Factory: Disabling TTS (none selected)'); this.activeHandler = null; // Save the preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'preferred_handler', 'none'); } // Dispatch event document.dispatchEvent(new CustomEvent('tts:handler:changed', { detail: { handler: 'none', available: false } })); this.updateTTSAvailability(); return true; } // Check if the handler exists if (!this.handlers[id]) { console.warn(`TTS Factory: Handler ${id} not registered - still setting as preferred`); // We'll still set the preference but won't set as active until it's registered // Save the preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'preferred_handler', id); } // We should not set this.activeHandler since the handler doesn't exist return false; } console.log(`TTS Factory: Setting active handler to ${id}`); // Check if the handler is ready (just for logging) const isReady = this.handlers[id].isReady === true; if (!isReady) { console.warn(`TTS Factory: Handler ${id} is not ready - TTS will be considered disabled until ready`); } // Stop any current speech if (this.activeHandler && this.handlers[this.activeHandler]) { this.handlers[this.activeHandler].stop(); } // Set the new active handler this.activeHandler = id; // Save the preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'preferred_handler', id); } // Dispatch event const event = new CustomEvent('tts:handler:changed', { detail: { handler: id, available: isReady } }); document.dispatchEvent(event); // Update overall TTS availability this.updateTTSAvailability(); return true; } /** * Get the active TTS handler * @returns {Object|null} - Active TTS handler instance or null if none active */ getActiveHandler() { if (!this.activeHandler || !this.handlers[this.activeHandler]) { return null; } return this.handlers[this.activeHandler]; } /** * Speak text using the active TTS handler * @param {string} text - Text to speak * @param {Object} options - TTS options * @returns {Promise} - Success status */ async speak(text, options = {}) { // Check if we have an active handler if (!this.activeHandler) { console.warn('TTS Factory: No active handler set'); return false; } // Get the active handler const handler = this.handlers[this.activeHandler]; if (!handler) { console.error('TTS Factory: Active handler not found'); return false; } // Check if the handler is ready if (!handler.isReady) { console.warn(`TTS Factory: Active handler ${this.activeHandler} is not ready`); return false; } try { // Apply speed option if specified const effectiveOptions = { ...options }; if (typeof effectiveOptions.speed === 'undefined') { effectiveOptions.speed = this.speed; } // Check if we have this speech cached const hash = await this.generateSpeechHash(text); const cached = await this.getCachedSpeech(hash); if (cached && cached.success) { console.log(`TTS Factory: Using cached speech for hash ${hash}`); // Use cached speech return handler.speakPreloaded(cached, result => { document.dispatchEvent(new CustomEvent('tts:speechCompleted', { detail: { success: result?.success === true, error: result?.error } })); }); } // Not cached, generate and cache if (typeof handler.preloadSpeech === 'function') { console.log(`TTS Factory: Generating and caching speech for hash ${hash}`); const preloadData = await handler.preloadSpeech(text); if (preloadData && preloadData.success) { // Cache the speech await this.cacheSpeech(hash, preloadData); // Speak the preloaded speech return handler.speakPreloaded(preloadData, result => { document.dispatchEvent(new CustomEvent('tts:speechCompleted', { detail: { success: result?.success === true, error: result?.error } })); }); } } // Fallback to direct speak if preloading failed or not supported console.log(`TTS Factory: Falling back to direct speak (no caching)`); return handler.speak(text, result => { // Forward speech completion event document.dispatchEvent(new CustomEvent('tts:speechCompleted', { detail: { success: result?.success === true, error: result?.error } })); }); } catch (error) { console.error('TTS Factory: Error speaking:', error); return false; } } /** * Preload speech for later playback * @param {string} text - Text to preload * @param {number} [priority=5] - Priority for preloading (1-10, higher is more important) * @returns {Promise} - Preloaded speech data */ async preloadSpeech(text, priority = 5) { // Check if we have an active handler if (!this.activeHandler || !this.ttsAvailable) { console.warn('TTS Factory: Cannot preload speech - no active handler or TTS not available'); return { success: false, reason: 'no_active_handler' }; } // Get the active handler const handler = this.handlers[this.activeHandler]; if (!handler) { return { success: false, reason: 'handler_not_found' }; } try { // Generate a hash for this speech request const hash = await this.generateSpeechHash(text); // Check if we have this speech cached const cached = await this.getCachedSpeech(hash); if (cached) { console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`); // Move this item to the end of the Map to mark it as most recently used // this.audioCache.delete(hash); // this.audioCache.set(hash, cached); this.cacheHits++; return cached; } // Cache miss - need to generate new speech data this.cacheMisses++; // If the handler has a preloadSpeech method, use it if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') { const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text); // Cache the generated speech data if (preloadData) { await this.cacheSpeech(hash, preloadData); console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`); } return preloadData; } else { console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`); return null; } } catch (error) { console.error("TTS Factory: Error preloading speech:", error); return null; } } /** * Stop speaking * @returns {boolean} - Success status */ stop() { // Check if we have an active handler if (!this.activeHandler || !this.ttsAvailable) { return false; } // Get the active handler const handler = this.handlers[this.activeHandler]; if (!handler) { return false; } // Call the handler's stop method try { return handler.stop(); } catch (error) { console.error('TTS Factory: Error stopping speech:', error); return false; } } /** * Pause speaking * @returns {boolean} - Success status */ pause() { if (!this.activeHandler) return false; try { return this.handlers[this.activeHandler].pause(); } catch (error) { console.error("Error pausing TTS:", error); return false; } } /** * Resume speaking * @returns {boolean} - Success status */ resume() { if (!this.activeHandler) return false; try { return this.handlers[this.activeHandler].resume(); } catch (error) { console.error("Error resuming TTS:", error); return false; } } /** * Get voices from the active handler * @returns {Array} - Array of voices */ async getVoices() { // Check if we have an active handler if (!this.activeHandler || !this.handlers[this.activeHandler]) { return []; } const handler = this.handlers[this.activeHandler]; try { // Return voices from handler if it supports it if (typeof handler.getVoices === 'function') { const voices = await handler.getVoices(); return voices || []; } // If no getVoices method, try accessing voices property if (Array.isArray(handler.voices)) { return handler.voices; } return []; } catch (error) { console.error('TTS Factory: Error getting voices:', error); return []; } } /** * Get a preference from the persistence manager * @param {string} category - Preference category * @param {string} key - Preference key * @param {*} defaultValue - Default value if preference doesn't exist * @returns {*} - Preference value */ getPreference(category, key, defaultValue) { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { return persistenceManager.getPreference(category, key, defaultValue); } return defaultValue; } /** * Check if any TTS handler is currently speaking * @returns {boolean} - True if speaking, false otherwise */ isSpeaking() { // Check active handler first if (this.activeHandler && this.handlers[this.activeHandler]) { if (this.handlers[this.activeHandler].isSpeaking) { return true; } } // Check all handlers return Object.values(this.handlers).some(handler => handler && typeof handler.isSpeaking === 'boolean' && handler.isSpeaking ); } /** * Update overall TTS availability */ updateTTSAvailability() { const wasAvailable = this.ttsAvailable; // TTS is considered available only if the active handler exists and is ready let ttsAvailable = false; if (this.activeHandler && this.handlers[this.activeHandler]) { // Check if the active handler is ready ttsAvailable = this.handlers[this.activeHandler].isReady === true; } this.ttsAvailable = ttsAvailable; console.log(`TTS Factory: Availability updated: ${this.ttsAvailable} (active handler: ${this.activeHandler || 'none'})`); // Only dispatch event if availability changed if (wasAvailable !== this.ttsAvailable) { // Notify the UI about TTS availability const event = new CustomEvent('tts:availability', { detail: { available: this.ttsAvailable, activeHandler: this.activeHandler } }); document.dispatchEvent(event); } } /** * Configure TTS settings for all handlers * @param {Object} options - TTS options * @param {number} [options.speed] - Normalized speech rate (0-1 range) */ configure(options = {}) { if (!options || typeof options !== 'object') { return; } // Handle speed option if (typeof options.speed === 'number') { // Save speed setting this.speed = Math.max(0.1, Math.min(3.0, options.speed)); // Save to preferences const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'speed', this.speed); } // Update all handlers for (const id in this.handlers) { const handler = this.handlers[id]; if (handler && typeof handler.setVoiceOptions === 'function') { handler.setVoiceOptions({ speed: this.speed }); } } } // Update UI that TTS settings have changed document.dispatchEvent(new CustomEvent('tts:configured', { detail: { options: { speed: this.speed }, activeHandler: this.activeHandler } })); } /** * Preload speech for a text * @param {string} text - Text to preload * @returns {Promise} - Resolves with preloaded speech data */ async preloadSpeech(text) { if (!this.activeHandler) { console.warn("TTS Factory: No active TTS handler for preload"); return null; } try { // Generate a hash for this speech request const hash = await this.generateSpeechHash(text); // Check if we have this audio in cache const cachedData = await this.getCachedSpeech(hash); if (cachedData) { console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`); // Move this item to the end of the Map to mark it as most recently used // this.audioCache.delete(hash); // this.audioCache.set(hash, cachedData); this.cacheHits++; return cachedData; } // Cache miss - need to generate new speech data this.cacheMisses++; // If the handler has a preloadSpeech method, use it if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') { const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text); // Cache the generated speech data if (preloadData) { await this.cacheSpeech(hash, preloadData); console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`); } return preloadData; } else { console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`); return null; } } catch (error) { console.error("TTS Factory: Error preloading speech:", error); return null; } } /** * Generate a unique hash for a speech request * @param {string} text - Text to generate hash for * @returns {Promise} - Hash string */ async generateSpeechHash(text) { // Get the active handler for voice information const handler = this.getActiveHandler(); // Include handler ID and voice options in the hash to ensure uniqueness across voices let voiceInfo = ''; if (handler && handler.voiceOptions && handler.voiceOptions.voice) { // Use the voice ID or name to identify the voice voiceInfo = handler.voiceOptions.voice.id || handler.voiceOptions.voice; } // Also include speed setting in the hash const speed = this.speed || 1.0; // Create a composite key for hashing const key = `${text}|${this.activeHandler}|${voiceInfo}|${speed}`; try { const encoder = new TextEncoder(); const data = encoder.encode(key); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert to hex string const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashHex; } catch (error) { console.error('TTS Factory: Error generating hash:', error); // Simple fallback hash if crypto API fails return key.replace(/[^a-z0-9]/gi, '').substring(0, 32); } } /** * Speak using preloaded speech data * @param {Object} preloadData - Preloaded speech data * @param {Object} options - Speech options * @returns {Promise} - Success status */ async speakPreloaded(preloadData, options = {}) { if (!this.activeHandler) { console.warn("TTS Factory: No active TTS handler for speak preloaded"); return false; } // If the handler has a speakPreloaded method, use it if (typeof this.handlers[this.activeHandler].speakPreloaded === 'function') { return await this.handlers[this.activeHandler].speakPreloaded(preloadData, options); } else { console.warn(`TTS Factory: Handler ${this.activeHandler} does not support speaking preloaded data`); return false; } } /** * Get cached speech data * @param {string} hash - Hash of the speech data * @returns {Promise} - Cached speech data or null if not found */ async getCachedSpeech(hash) { if (!this.db || this.cacheStatus !== 'ready') { console.warn('TTS Factory: Cache not ready, cannot retrieve cached speech'); return null; } try { const item = await this._getDBItem(hash); if (item && item.audioData) { console.log(`TTS Factory: Found cached speech for hash ${hash}`); return item.audioData; } } catch (error) { console.error('TTS Factory: Error retrieving cached speech:', error); } return null; } /** * Add speech data to the cache * @param {string} hash - Hash of the speech data * @param {ArrayBuffer} audioData - Audio data to cache * @returns {Promise} - Success status */ async cacheSpeech(hash, audioData) { if (!this.db || this.cacheStatus !== 'ready') { console.warn('TTS Factory: Cache not ready, cannot cache speech'); return false; } if (!audioData) { console.error('TTS Factory: No audio data provided to cache'); return false; } try { // Make sure we have room in the cache await this.manageCacheSize(audioData.byteLength); // Store the speech data await this._putDBItem(hash, audioData); console.log(`TTS Factory: Cached speech for hash ${hash}`); return true; } catch (error) { console.error('TTS Factory: Error caching speech:', error); return false; } } /** * Manages the cache size, ensuring it doesn't exceed the limit using LRU. * @param {number} [sizeToAdd] - Optional size to add to the cache before checking * @returns {Promise} */ async manageCacheSize(sizeToAdd = 0) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("TTSFactory: Cache DB not ready for size management."); return; } let iterations = 0; const maxIterations = 100; // Safety break to prevent infinite loops try { // Ensure currentCacheSize is up-to-date before starting eviction // This is important especially on startup or if background writes happened this.currentCacheSize = await this._calculateTotalCacheSize(); console.log(`TTS Factory: Recalculated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`); // Add the size to be added to the current size this.currentCacheSize += sizeToAdd; while (this.currentCacheSize > this.maxCacheSizeBytes && iterations < maxIterations) { iterations++; console.log(`TTS Factory: Cache limit exceeded (${(this.currentCacheSize / (1024*1024)).toFixed(2)}MB > ${(this.maxCacheSizeBytes / (1024*1024)).toFixed(2)}MB). Evicting oldest entry.`); const sortedItems = await this._getAllDBItemsSortedByAccess(); if (sortedItems.length === 0) { console.warn("TTS Factory: Cache size exceeds limit, but no items found to evict."); this.currentCacheSize = 0; // Reset size if store is empty break; // Exit loop } const oldestItem = sortedItems[0]; console.log(`TTS Factory: Evicting item with hash ${oldestItem.hash}, size ${oldestItem.size}, lastAccessed ${new Date(oldestItem.lastAccessed).toISOString()}`); await this._deleteDBItem(oldestItem.hash); if (typeof oldestItem.size === 'number') { this.currentCacheSize -= oldestItem.size; } else { // Size was invalid, recalculate total size for safety console.warn(`TTS Factory: Evicted item ${oldestItem.hash} had invalid size. Recalculating total size.`); this.currentCacheSize = await this._calculateTotalCacheSize(); } console.log(`TTS Factory: New estimated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`); } if (iterations >= maxIterations) { console.error("TTS Factory: Max iterations reached during cache eviction. Cache might still be oversized."); } } catch (error) { console.error("TTS Factory: Error during cache size management:", error); // Consider setting cache status to error or attempting recovery } } /** * Checks if speech for the given text is likely cached. * @param {string} text - The original text. * @returns {Promise} */ async isSpeechCached(text) { if (!this.cacheInitialized && this.cacheStatus !== 'ready') { console.warn("TTSFactory: Cache not ready for checking."); return false; } const handler = this.getActiveHandler(); if (!handler) return false; const hash = await this.generateSpeechHash(text); try { const item = await this._getDBItem(hash); // _getDBItem updates timestamp if found return !!item; } catch (error) { console.error(`TTS Factory: Error checking cache for hash ${hash}:`, error); return false; } } /** * Opens and initializes the IndexedDB database. */ async _initializeDB() { return new Promise((resolve, reject) => { if (this.db) { resolve(); // Already initialized return; } const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = (event) => { console.error("IndexedDB error:", event.target.error); this.cacheStatus = 'error'; reject(new Error(`IndexedDB error: ${event.target.error.message}`)); }; request.onsuccess = (event) => { this.db = event.target.result; console.log("IndexedDB initialized successfully."); this.cacheStatus = 'ready'; // Calculate initial size after successful opening this._calculateTotalCacheSize().then(size => { this.currentCacheSize = size; console.log(`Initial cache size: ${(size / (1024*1024)).toFixed(2)} MB`); resolve(); }).catch(error => { console.error("Error calculating initial cache size:", error); this.cacheStatus = 'error'; reject(error); // Propagate calculation error }); }; request.onupgradeneeded = (event) => { console.log("IndexedDB upgrade needed."); const db = event.target.result; if (!db.objectStoreNames.contains(this.storeName)) { const store = db.createObjectStore(this.storeName, { keyPath: 'hash' }); // Index for LRU eviction store.createIndex('lastAccessed', 'lastAccessed', { unique: false }); // Index to potentially help with size calculation, though iterating might be needed anyway store.createIndex('size', 'size', { unique: false }); console.log(`Object store '${this.storeName}' created.`); } else { // Handle potential future schema upgrades here if needed console.log(`Object store '${this.storeName}' already exists.`); const transaction = event.target.transaction; const store = transaction.objectStore(this.storeName); // Ensure indexes exist if upgrading from a version without them if (!store.indexNames.contains('lastAccessed')) { store.createIndex('lastAccessed', 'lastAccessed', { unique: false }); console.log("Created 'lastAccessed' index."); } if (!store.indexNames.contains('size')) { store.createIndex('size', 'size', { unique: false }); console.log("Created 'size' index."); } } }; }); } /** * Gets an item from the IndexedDB store and updates its lastAccessed timestamp. * @param {string} hash - The key (hash) of the item to retrieve. * @returns {Promise} - The cached item object or null if not found. */ async _getDBItem(hash) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot get item."); return null; } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); // Need readwrite to update timestamp const store = transaction.objectStore(this.storeName); const request = store.get(hash); request.onerror = (event) => { console.error("Error getting item from IndexedDB:", event.target.error); reject(event.target.error); }; request.onsuccess = (event) => { const result = event.target.result; if (result) { // Update lastAccessed timestamp result.lastAccessed = Date.now(); const updateRequest = store.put(result); updateRequest.onerror = (updateEvent) => { console.error("Error updating lastAccessed timestamp:", updateEvent.target.error); // Still resolve with data, timestamp update failure is non-critical for retrieval resolve(result); }; updateRequest.onsuccess = () => { // console.log(`Updated lastAccessed for hash: ${hash}`); resolve(result); }; } else { resolve(null); // Not found } }; transaction.oncomplete = () => { // Transaction completed (either get or get+update) }; transaction.onerror = (event) => { console.error("Readwrite transaction error during get/update:", event.target.error); // If transaction failed before request.onsuccess, we need to reject if (!request.result) { // Check if we already resolved reject(event.target.error); } }; }); } /** * Adds or updates an item in the IndexedDB store. * @param {string} hash - The key (hash) of the item to store. * @param {ArrayBuffer} audioData - The audio data to cache. * @returns {Promise} */ async _putDBItem(hash, audioData) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot put item."); return Promise.reject(new Error("IndexedDB not ready")); } if (!hash || !audioData) { console.error("Invalid item provided to _putDBItem:", hash, audioData); return Promise.reject(new Error("Invalid item format")); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.put({ hash, audioData, size: audioData.byteLength, lastAccessed: Date.now() }); request.onerror = (event) => { console.error("Error putting item into IndexedDB:", event.target.error); reject(event.target.error); }; request.onsuccess = () => { // console.log(`Successfully put item with hash: ${hash}`); resolve(); }; transaction.onerror = (event) => { console.error("Readwrite transaction error during put:", event.target.error); reject(event.target.error); }; }); } /** * Deletes an item from the IndexedDB store. * @param {string} hash - The key (hash) of the item to delete. * @returns {Promise} */ async _deleteDBItem(hash) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot delete item."); return Promise.reject(new Error("IndexedDB not ready")); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const request = store.delete(hash); request.onerror = (event) => { console.error("Error deleting item from IndexedDB:", event.target.error); reject(event.target.error); }; request.onsuccess = () => { // console.log(`Successfully deleted item with hash: ${hash}`); resolve(); }; transaction.onerror = (event) => { console.error("Readwrite transaction error during delete:", event.target.error); reject(event.target.error); }; }); } /** * Calculates the total size of all items currently in the cache. * @returns {Promise} - The total size in bytes. */ async _calculateTotalCacheSize() { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot calculate size."); return 0; } return new Promise((resolve, reject) => { let totalSize = 0; const transaction = this.db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const cursorRequest = store.openCursor(); cursorRequest.onerror = (event) => { console.error("Error opening cursor for size calculation:", event.target.error); reject(event.target.error); }; cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { // Check if size property exists and is a number if (typeof cursor.value.size === 'number') { totalSize += cursor.value.size; } else { console.warn(`Item with hash ${cursor.key} missing or invalid size property.`); // Optionally try to get blob size here, but might be slow } cursor.continue(); } else { // No more entries resolve(totalSize); } }; transaction.onerror = (event) => { console.error("Readonly transaction error during size calculation:", event.target.error); reject(event.target.error); }; }); } /** * Gets all items sorted by lastAccessed timestamp (ascending, oldest first). * @returns {Promise>} - Array of cache item objects. */ async _getAllDBItemsSortedByAccess() { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot get sorted items."); return []; } return new Promise((resolve, reject) => { const items = []; const transaction = this.db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const index = store.index('lastAccessed'); // Use the index const cursorRequest = index.openCursor(); // Open cursor on the index cursorRequest.onerror = (event) => { console.error("Error opening cursor on lastAccessed index:", event.target.error); reject(event.target.error); }; cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { items.push(cursor.value); // Add the object to the array cursor.continue(); } else { // No more entries resolve(items); } }; transaction.onerror = (event) => { console.error("Readonly transaction error during sorted get:", event.target.error); reject(event.target.error); }; }); } /** * Helper to get item data without updating the lastAccessed timestamp. * Used internally by cacheSpeech to check existing size. * @param {string} hash * @returns {Promise} */ async _getDBItemOnly(hash) { if (!this.db || this.cacheStatus !== 'ready') return null; return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.get(hash); request.onerror = (event) => reject(event.target.error); request.onsuccess = (event) => resolve(event.target.result || null); }); } /** * Generates a SHA-256 hash for the given string. * @param {string} text - Input text. * @returns {Promise} - Hexadecimal hash string. */ async _generateHash(text) { try { const encoder = new TextEncoder(); const data = encoder.encode(text); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert to hex string const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashHex; } catch (error) { console.error("Error generating SHA-256 hash:", error); // Fallback to simple text if crypto fails (less ideal for caching complex text) return text.replace(/[^a-zA-Z0-9]/g, ''); // Basic fallback } } /** * Get a TTS handler by ID * @param {string} id - Handler ID * @returns {Object|null} - TTS handler instance or null if not found */ getHandler(id) { if (!id || !this.handlers[id]) return null; return this.handlers[id]; } /** * Attempt to initialize and set the preferred handler * @returns {Promise} - Success status */ async initiatePreferredHandler() { // Get the preferred handler from persistence manager const persistenceManager = this.getModule('persistence-manager'); let preferredHandler = null; if (persistenceManager) { preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`); } // Try to initialize and set the preferred handler if (preferredHandler && this.handlers[preferredHandler]) { console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); // Try to initialize the preferred handler const success = await this.initializeHandler(preferredHandler); if (success) { console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`); return await this.setActiveHandler(preferredHandler); } else { console.warn(`TTS Factory: Preferred handler ${preferredHandler} initialization failed, trying fallbacks`); } } // If we couldn't initialize the preferred handler, try fallbacks return this.attemptFallbackHandler(); } /** * Attempt to initialize and set fallback handlers in order * @returns {Promise} - Success status */ async attemptFallbackHandler() { // Fallback order: Kokoro -> Browser -> None const fallbackOrder = ['kokoro', 'browser']; // Try each fallback in order for (const handlerId of fallbackOrder) { if (this.handlers[handlerId]) { console.log(`TTS Factory: Trying fallback handler: ${handlerId}`); // Try to initialize this handler const success = await this.initializeHandler(handlerId); if (success) { console.log(`TTS Factory: Fallback handler ${handlerId} initialized successfully`); return await this.setActiveHandler(handlerId); } else { console.warn(`TTS Factory: Fallback handler ${handlerId} initialization failed`); } } } // If all fallbacks failed, update TTS availability console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable'); this.activeHandler = null; this.updateTTSAvailability(); // TTS is optional, so return true even if no handler is available return true; } /** * Debug TTS handlers and their status * This will log detailed information about all TTS handlers */ debugTTSHandlers() { console.log('===== DEBUG TTS HANDLERS START ====='); // Log all registered handlers console.log('Registered Handlers:'); for (const id in this.handlers) { const handler = this.handlers[id]; const isInitialized = !!this.initStatus[id]; const isReady = handler && handler.isReady; const isApiHandler = ['elevenlabs', 'openai', 'kokoro'].includes(id); console.log(`Handler ID: ${id}`); console.log(` - Handler Exists: ${!!handler}`); console.log(` - Is API Handler: ${isApiHandler}`); console.log(` - Init Status: ${isInitialized}`); console.log(` - Is Ready: ${isReady}`); console.log(` - Would Include in UI: ${isApiHandler || isInitialized || isReady}`); // Check handler properties if (handler) { console.log(` - Type: ${handler.constructor.name}`); console.log(` - Module ID: ${handler.id}`); } } // Check what getAvailableHandlers is returning const availableHandlers = this.getAvailableHandlers(); console.log('\ngetAvailableHandlers() returns:', availableHandlers.map(h => h.id)); // Check module registry console.log('\nModules in Registry:'); const registry = window.moduleRegistry; if (registry && registry.modules) { // Find all TTS-related modules const ttsModules = Object.keys(registry.modules).filter(id => { const module = registry.modules[id]; return module && ( id === 'tts-factory' || id === 'kokoro' || id === 'browser' || id === 'elevenlabs' || id === 'openai' ); }); ttsModules.forEach(id => { const module = registry.modules[id]; console.log(` - Module ID: ${id}`); console.log(` - Type: ${module.constructor.name}`); console.log(` - Is Initialized: ${module.isInitialized}`); }); } else { console.log(' Module Registry not available'); } console.log('===== DEBUG TTS HANDLERS END ====='); } /** * For debugging: Log all registered modules in the registry */ debugLogAllRegisteredModules() { console.log('=== DEBUG: All registered modules ==='); if (window.moduleRegistry && window.moduleRegistry.modules) { const moduleIds = Object.keys(window.moduleRegistry.modules); moduleIds.forEach(id => { const module = window.moduleRegistry.modules[id]; console.log(`Module [${id}]: ${module.constructor.name}`); }); console.log(`Total modules: ${moduleIds.length}`); } else { console.log('Module registry not available'); } console.log('=== END DEBUG ==='); } /** * Clean up when module is disposed */ dispose() { // Stop any active TTS if (this.activeHandler) { this.handlers[this.activeHandler].stop(); } // Dispose all handlers for (const id in this.handlers) { if (this.handlers[id].dispose) { this.handlers[id].dispose(); } } // Clear handlers this.handlers = {}; this.activeHandler = null; } } // Create module instance const TTSFactory = new TTSFactoryModule(); // Export the module export { TTSFactory };