/** * TTS Factory Module * Manages TTS handler instances */ import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; import { BrowserTTSHandler } from './browser-tts-handler.js'; import { KokoroHandler } from './kokoro-handler.js'; import { ElevenLabsTTSHandler } from './elevenlabs-tts-handler.js'; import { OpenAITTSHandler } from './openai-tts-handler.js'; class TTSFactoryModule extends BaseModule { /** * Create a new TTS factory */ constructor() { super('tts-factory', 'TTS Factory'); this.dependencies = ['persistence-manager', 'localization']; 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 = 1 * 1024 * 1024 * 1024; // 1 GB limit this.cacheInitialized = false; // Cache status indicator (could be used in UI later) this.cacheStatus = 'initializing'; // initializing, ready, error // 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(); } }); // Bind methods this.bindMethods([ 'registerHandler', 'initializeHandler', 'getHandler', 'setActiveHandler', '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' ]); } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { this.reportProgress(10, "Initializing TTS factory"); // Get dependencies const persistenceManager = this.getModule('persistence-manager'); const localization = this.getModule('localization'); if (!persistenceManager || !localization) { console.error("TTS Factory: Required dependencies not found"); this.reportProgress(100, "TTS factory failed - missing dependencies"); return false; } // Reset any previous state this.initStatus = {}; for (const id in this.handlers) { this.initStatus[id] = false; } this.reportProgress(20, "Registering TTS handlers"); // Register handlers // Following correct fallback order: Kokoro -> Browser -> None (API requires manual config) this.registerHandler('kokoro', new KokoroHandler()); this.registerHandler('browser', new BrowserTTSHandler()); this.registerHandler('elevenlabs', new ElevenLabsTTSHandler()); this.registerHandler('openai', new OpenAITTSHandler()); this.reportProgress(30, "Initializing handlers"); // Initialize all handlers in parallel const initPromises = Object.keys(this.handlers).map(id => this.initializeHandler(id)); await Promise.all(initPromises); this.reportProgress(60, "All handlers initialized"); // Get TTS preferences const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false); const preferredProvider = persistenceManager.getPreference('tts', 'provider', 'none'); console.log(`TTS Factory: TTS enabled: ${ttsEnabled}, preferred provider: ${preferredProvider}`); this.reportProgress(70, `TTS preferences loaded: enabled=${ttsEnabled}, provider=${preferredProvider}`); // Set active handler based on preferences if (ttsEnabled) { // Determine fallback order - Kokoro -> Browser -> None (API requires manual config) const fallbackOrder = ['kokoro', 'browser']; // Try to set the preferred provider first let success = false; if (preferredProvider && preferredProvider !== 'none') { success = await this.setActiveHandler(preferredProvider); } // If preferred provider failed or wasn't specified, try the fallback order if (!success) { console.log('TTS Factory: Preferred provider unavailable, trying fallbacks'); for (const id of fallbackOrder) { if (this.handlers[id] && this.initStatus[id]) { console.log(`TTS Factory: Trying fallback provider: ${id}`); success = await this.setActiveHandler(id); if (success) { console.log(`TTS Factory: Using fallback provider: ${id}`); break; } } } } if (!success) { console.warn('TTS Factory: No viable TTS provider found'); } } else { console.log('TTS Factory: TTS is disabled in preferences'); } // Determine overall TTS availability // Any handler that's initialized should count towards availability this.ttsAvailable = Object.values(this.initStatus).some(status => status === true); console.log('TTS Factory: Overall TTS availability:', this.ttsAvailable); console.log('TTS Factory: Handler status:', this.initStatus); // Dispatch TTS availability event window.dispatchEvent(new CustomEvent('tts:availability', { detail: { available: this.ttsAvailable } })); this.reportProgress(100, "TTS factory initialized"); return true; // TTS is optional, so always return true } catch (error) { console.error("TTS Factory: Error during initialization:", error); this.reportProgress(100, "TTS factory failed"); return true; // TTS is optional, so always return true } } /** * Register a TTS handler * @param {string} id - Handler ID * @param {Object} handler - TTS handler instance */ registerHandler(id, handler) { if (!id || !handler) return; this.handlers[id] = handler; } /** * 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`); return false; } console.log(`TTS Factory: Initializing handler ${id}`); const progressCallback = (progress, message) => { const mappedProgress = (progress / 100) || 0; console.log(`TTS Factory: Handler ${id} progress: ${progress}%, ${message}`); this.reportProgress(50 + Math.round(mappedProgress * 40), `Initializing ${id}: ${message}`); }; try { // Initialize the handler with progress callback const success = await this.handlers[id].initialize(progressCallback); this.initStatus[id] = success; if (success) { console.log(`TTS Factory: Handler ${id} initialized successfully`); // Force getVoices() to ensure voices are loaded const voices = this.handlers[id].getVoices(); console.log(`TTS Factory: Handler ${id} has ${voices ? voices.length : 0} voices available after initialization`); } else { console.warn(`TTS Factory: Handler ${id} initialization failed`); } return success; } catch (error) { console.error(`TTS Factory: Error initializing handler ${id}:`, error); this.initStatus[id] = false; return false; } } /** * 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]; } /** * Set the active TTS handler * @param {string} id - Handler ID * @returns {boolean} - Success status */ setActiveHandler(id) { // If 'none' is passed, disable TTS if (id === 'none') { this.activeHandler = null; this.ttsAvailable = false; // Notify about TTS availability change document.dispatchEvent(new CustomEvent('tts:availability', { detail: { available: false } })); // Notify about handler change document.dispatchEvent(new CustomEvent('tts:handlerChanged', { detail: { handlerId: 'none' } })); console.log('TTS Factory: TTS disabled'); return true; } // Check if the handler exists if (!this.handlers[id]) { console.error(`TTS Factory: Handler not found: ${id}`); return false; } if (!this.initStatus[id]) { console.error(`TTS Factory: Handler not initialized: ${id}`); return false; } this.activeHandler = id; // Update TTS availability state this.ttsAvailable = true; // Notify about TTS availability change document.dispatchEvent(new CustomEvent('tts:availability', { detail: { available: true } })); // Notify about handler change document.dispatchEvent(new CustomEvent('tts:handlerChanged', { detail: { handlerId: id } })); console.log(`TTS Factory: Active handler set to ${id}`); return true; } /** * Get the active TTS handler * @returns {Object|null} - Active TTS handler instance or null if none active */ getActiveHandler() { if (!this.activeHandler) return null; return this.handlers[this.activeHandler]; } /** * Get available TTS handlers * @returns {Array} - Array of handler objects */ getAvailableHandlers() { const availableHandlers = []; // Always show all initialized handlers in the options dropdown, // regardless of availability status. This ensures API handlers are configurable // even when the API key is not set. for (const id in this.handlers) { // Only include handlers that have been initialized if (this.handlers[id] && this.initStatus[id]) { console.log(`TTS Factory: Handler ${id} is initialized, adding to available handlers list`); availableHandlers.push({ id: id, handler: this.handlers[id] }); } } if (availableHandlers.length === 0) { console.warn('TTS Factory: No available handlers found - something is wrong!'); } else { console.log(`TTS Factory: Found ${availableHandlers.length} available handlers`); } return availableHandlers; } /** * 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 = {}) { if (!this.activeHandler) { console.warn("No active TTS handler"); return false; } const handler = this.handlers[this.activeHandler]; if (!handler || !handler.isReady) { console.warn(`TTS handler ${this.activeHandler} is not ready`); return false; } // Special case for browser TTS - don't use caching if (this.activeHandler === 'browser') { return handler.speak(text, options); } // For other handlers (API, Kokoro), use caching const hash = await this._generateHash(text + handler.getCurrentVoiceIdentifier()); let audioData = null; try { // 1. Check Cache console.log(`TTSFactory: Checking cache for hash ${hash}`); audioData = await this.getCachedSpeech(hash); if (audioData) { console.log(`TTSFactory: Found cached audio for hash ${hash}`); } else { // 2. Generate Speech if not in cache console.log(`TTSFactory: Generating speech for hash ${hash}`); audioData = await handler.speak(text); if (!audioData) { throw new Error(`Failed to generate speech for text: ${text.substring(0, 20)}...`); } // 3. Cache the Result await this.cacheSpeech(hash, audioData); } // 4. Play Audio (either cached or newly generated) if (audioData) { const audioManager = this.getModule('audio-manager'); if (!audioManager) throw new Error('AudioManager module not found'); // Use the new playSpeech method that handles speech audio blobs await audioManager.playSpeech(audioData, options); // Pass original options console.log(`TTSFactory: Playback initiated for hash ${hash}`); return true; } else { throw new Error('No audio data available to play after cache check and generation.'); } } catch (error) { console.error(`TTSFactory: Error during speak process for hash ${hash}:`, error); return false; } } /** * Preload speech audio for given text using the active handler. * Handles caching automatically. * @param {string} text - Text to synthesize. * @param {number} [priority=5] - Priority for preloading. * @returns {Promise} - True if preload finished successfully (either generated or already cached). */ async preloadSpeech(text, priority = 5) { if (!this.isAvailable || !this.activeHandler) { return false; // Cannot preload if TTS is unavailable } const handler = this.handlers[this.activeHandler]; if (!handler || !handler.isReady) { console.warn(`TTSFactory: Active handler (${this.activeHandler}) not ready for preload.`); return false; } // Browser TTS uses Web Speech API directly and is not preloaded/cached here if (this.activeHandler === 'browser') { console.log("TTSFactory: Skipping preload for Browser TTS."); return true; // Consider it 'preloaded' as it's always ready locally } // Check if the handler supports preloading at all if (typeof handler.preloadSpeech !== 'function') { console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`); return false; // Cannot fulfill preload request } const hash = await this._generateHash(text + handler.getCurrentVoiceIdentifier()); try { // 1. Check Cache console.log(`TTSFactory: Checking preload cache for hash: ${hash}`); const cachedAudio = await this.getCachedSpeech(hash); if (cachedAudio) { console.log(`TTS Factory: Preload cache hit for hash ${hash}.`); this.cacheHits = (this.cacheHits || 0) + 1; return true; // Already cached } console.log(`TTSFactory: Preload cache miss for hash ${hash}. Requesting preload generation from handler: ${this.activeHandler}`); this.cacheMisses = (this.cacheMisses || 0) + 1; // 2. Generate Audio via Handler Preload // Handler's preloadSpeech method should now return the Blob const audioData = await handler.preloadSpeech(text, priority); if (!audioData || !(audioData instanceof Blob)) { console.warn(`TTSFactory: Handler ${this.activeHandler} preloadSpeech did not return valid audio Blob for hash ${hash}.`); return false; // Preload failed if no data returned } console.log(`TTSFactory: Handler ${this.activeHandler} generated preload audio Blob.`); // 3. Cache the Result await this.cacheSpeech(hash, audioData); return true; // Successfully preloaded and cached } catch (error) { console.error(`TTSFactory: Error during preloadSpeech for hash ${hash}:`, error); return false; } } /** * Stop speaking * @returns {boolean} - Success status */ stop() { if (!this.activeHandler) return false; try { return this.handlers[this.activeHandler].stop(); } catch (error) { console.error("Error stopping TTS:", 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 */ getVoices() { // Get the active handler const handler = this.getActiveHandler(); // Check if we have an active handler if (!handler) { console.log('TTS Factory: No active handler, returning empty voices array'); return []; } // Get voices from the active handler const voices = handler.getVoices(); console.log(`TTS Factory: Retrieved ${voices ? voices.length : 0} voices from ${this.activeHandler}`); // Check if we have any voices if (!voices || voices.length === 0) { console.warn(`TTS Factory: No voices retrieved from ${this.activeHandler} handler`); return []; } // Return voices return voices; } /** * 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() { if (!this.activeHandler || !this.handlers[this.activeHandler]) { return false; } try { return this.handlers[this.activeHandler].isSpeaking(); } catch (error) { console.error("Error checking speaking status:", error); return false; } } /** * Update overall TTS availability */ updateTTSAvailability() { this.ttsAvailable = this.initStatus.kokoro || this.initStatus.browser; // Dispatch TTS availability event window.dispatchEvent(new CustomEvent('tts:availability', { detail: { available: this.ttsAvailable } })); } /** * Configure TTS settings for all handlers * @param {Object} options - TTS options * @param {number} [options.speed] - Normalized speech rate (0-1 range) */ configure(options = {}) { // If speed is provided, convert the normalized speed (0-1) to the appropriate scale for each handler if (typeof options.speed === 'number') { const normalizedSpeed = Math.max(0, Math.min(1, options.speed)); // Scale for each handler type for (const id in this.handlers) { // Ensure the handler exists and has the setVoiceOptions method if (this.handlers[id] && typeof this.handlers[id].setVoiceOptions === 'function') { let scaledOptions = {}; // Scale the speed value appropriately for each handler type if (id === 'browser') { // Browser TTS uses rate from 0.1 to 2.0 scaledOptions.rate = 0.1 + (normalizedSpeed * 1.9); } else if (id === 'kokoro') { // Kokoro uses rate from 0.5 to 1.5 scaledOptions.rate = 0.5 + (normalizedSpeed); } else if (id === 'elevenlabs' || id === 'openai') { // ElevenLabs and OpenAI use speed from 0.5 to 2.0 scaledOptions.speed = 0.5 + (normalizedSpeed * 1.5); } // Apply the scaled options to the handler this.handlers[id].setVoiceOptions(scaledOptions); } } // Store the normalized value this.speed = normalizedSpeed; console.log(`TTS Factory: Speed set to ${normalizedSpeed} (normalized), ${Math.round(normalizedSpeed * 100)}/100`); } return true; } /** * 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 already 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) { if (!this.activeHandler) return null; // Get voice ID and other parameters const handler = this.handlers[this.activeHandler]; const handlerId = this.activeHandler; const voiceId = handler.voiceOptions?.voice?.id || 'default'; const speed = this.speed; // Create a string to hash const dataToHash = `${handlerId}_${voiceId}_${speed}_${text}`; // Use SubtleCrypto to create a SHA-256 hash if available try { const encoder = new TextEncoder(); const data = encoder.encode(dataToHash); 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) { // Fallback to simple string hash if SubtleCrypto is not available console.warn('TTS Factory: Unable to generate crypto hash, using fallback', error); let hash = 0; for (let i = 0; i < dataToHash.length; i++) { const char = dataToHash.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash).toString(16); } } /** * 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("IndexedDB not ready, cannot get item."); return null; } try { const data = await this._getDBItem(hash); if (data) { console.log(`TTS Factory: Cache hit for hash ${hash}`); } else { console.log(`TTS Factory: Cache miss for hash ${hash}`); } return data; } catch (error) { console.error(`TTS Factory: Error getting cached speech for hash ${hash}:`, error); return null; } } /** * Add speech data to the cache * @param {string} hash - Hash of the speech data * @param {Blob} audioData - The audio data to cache * @returns {Promise} */ async cacheSpeech(hash, audioData) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot cache speech."); return; } if (!(audioData instanceof Blob) || audioData.size === 0) { console.warn("TTSFactory: Invalid audio data provided for caching."); return; } const handler = this.getActiveHandler(); if (!handler) { console.warn("TTSFactory: No active handler, cannot determine voice identifier for cache key."); return; } const size = audioData.size; const lastAccessed = Date.now(); const newItem = { hash, data: audioData, size, lastAccessed }; try { // Check if item already exists to correctly update cache size const existingItem = await this._getDBItemOnly(hash); // Helper needed to get without updating timestamp if (existingItem && typeof existingItem.size === 'number') { this.currentCacheSize -= existingItem.size; // Subtract old size } await this._putDBItem(newItem); this.currentCacheSize += size; // Add new size console.log(`TTS Factory: Cached speech for hash ${hash}. New size: ${size}. Total cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`); // Trigger size check asynchronously this.manageCacheSize().catch(error => { console.error("TTS Factory: Error during post-cache size management:", error); }); } catch (error) { console.error(`TTS Factory: Error caching speech for hash ${hash}:`, error); // Attempt to revert cache size change if put failed? // Might be complex, log and potentially mark cache as unhealthy } } /** * Manages the cache size, ensuring it doesn't exceed the limit using LRU. * @returns {Promise} */ async manageCacheSize() { 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`); 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 audio data Blob 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.data); }; updateRequest.onsuccess = () => { // console.log(`Updated lastAccessed for hash: ${hash}`); resolve(result.data); }; } 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 {object} item - The item object { hash: string, data: Blob, size: number, lastAccessed: number }. * @returns {Promise} */ async _putDBItem(item) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("IndexedDB not ready, cannot put item."); return Promise.reject(new Error("IndexedDB not ready")); } if (!item || !item.hash || !item.data || item.size === undefined || item.lastAccessed === undefined) { console.error("Invalid item provided to _putDBItem:", item); 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(item); 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: ${item.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 } } /** * 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 the singleton instance const TTSFactory = new TTSFactoryModule(); // Register with the module registry moduleRegistry.register(TTSFactory); // Export the module export { TTSFactory };