From e5a3016846e9ca9e184573603ba788911df72eb6 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 5 Apr 2025 17:23:01 +0000 Subject: [PATCH] Reference: Semi broken tts before refactoring --- public/js/api-tts-handler-base.js | 330 +++++----- public/js/audio-manager.js | 94 +++ public/js/elevenlabs-tts-handler.js | 4 +- public/js/openai-tts-handler.js | 5 + public/js/options-ui.js | 58 +- public/js/patterns/{de.wasm => de-de.wasm} | Bin public/js/persistence-manager.js | 5 - public/js/tts-factory.js | 732 +++++++++++++++++---- public/js/tts-handler.js | 14 + 9 files changed, 909 insertions(+), 333 deletions(-) rename public/js/patterns/{de.wasm => de-de.wasm} (100%) diff --git a/public/js/api-tts-handler-base.js b/public/js/api-tts-handler-base.js index cf1c1f9..dd1bb5d 100644 --- a/public/js/api-tts-handler-base.js +++ b/public/js/api-tts-handler-base.js @@ -83,6 +83,10 @@ export class ApiTTSHandlerBase extends TTSHandler { persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl); } + // Log the current values for debugging + console.log(`${this.name} API KEY: ${this.apiKey ? '[SET]' : '[EMPTY]'}`); + console.log(`${this.name} API URL: ${this.apiBaseUrl}`); + if (progressCallback) { progressCallback(40, `${this.name} API URL set to: ${this.apiBaseUrl}`); } @@ -114,7 +118,7 @@ export class ApiTTSHandlerBase extends TTSHandler { this.isReady = true; if (progressCallback) { - const statusMessage = this.available ? + const statusMessage = this.apiKey ? `${this.name} initialized successfully` : `${this.name} initialized but unavailable (API key missing)`; progressCallback(100, statusMessage); @@ -207,189 +211,157 @@ export class ApiTTSHandlerBase extends TTSHandler { } /** - * Preload speech for a text - * @param {string} text - Text to preload - * @returns {Promise} - Preloaded audio data + * Generate speech audio blob for the given text using the API. + * Does not handle caching or playback, returns the Blob directly. + * @param {string} text - The text to synthesize. + * @returns {Promise} - A promise that resolves with the audio Blob, or null on failure. */ - async preloadSpeech(text) { - // Don't try to preload if handler isn't ready, available, or if no text or API key - if (!this.isReady || !this.available || !text || !this.apiKey) { - if (!this.apiKey) { - console.log(`${this.name}: Skipping preload speech - no API key set`); - } + async generateSpeechAudio(text) { + if (!this.apiKey) { + console.error(`${this.name}: API key is not set.`); return null; } - + if (!this.isReady || !this.currentVoice) { + console.error(`${this.name}: Handler not ready or no voice selected.`); + return null; + } + + const requestUrl = this.getApiRequestUrl(); + const requestBody = this.getApiRequestBody(text); + const requestHeaders = this.getApiRequestHeaders(); + + console.log(`${this.name}: Requesting speech generation...`); + // Log sensitive info only if debug enabled (assuming a global DEBUG flag or similar) + // if (DEBUG) { + // console.debug(`${this.name}: URL: ${requestUrl}`); + // console.debug(`${this.name}: Headers:`, JSON.stringify(requestHeaders)); + // console.debug(`${this.name}: Body:`, JSON.stringify(requestBody)); + // } + try { - // Process text for TTS - const processedText = this.preprocessText(text); - - // Generate speech audio data - const audioData = await this.generateSpeechAudio(processedText); + const response = await fetch(requestUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + let errorBody = 'Unknown error'; + try { + errorBody = await response.text(); // Try to get text first + const errorJson = JSON.parse(errorBody); // Try to parse as JSON + errorBody = errorJson.error?.message || errorJson.detail || JSON.stringify(errorJson); + } catch (e) { + // If parsing fails or it's not JSON, use the raw text + console.warn(`${this.name}: Could not parse error response as JSON. Raw text: ${errorBody}`); + } + throw new Error(`API Error (${response.status} ${response.statusText}): ${errorBody}`); + } + + // --- Response Handling (Specific to API - Override if necessary) --- + // Default assumes response IS the audio blob + const audioBlob = await response.blob(); + console.log(`${this.name}: Received audio blob, size: ${audioBlob.size}`); + // ------------------------------------------------------------------- + + if (!audioBlob || audioBlob.size === 0) { + throw new Error('Received empty audio blob from API.'); + } + // Return the audio data blob + return audioBlob; + + } catch (error) { + console.error(`${this.name}: Error generating speech audio:`, error); + this.handleApiError(error); + return null; + } + } + + /** + * Plays preloaded audio data. + * @param {Blob} audioData - The audio data Blob to play. + * @param {Function} [callback=null] - Optional callback function. + */ + speakPreloaded(audioData, callback = null) { + // This method might now be redundant if the factory handles all playback. + // However, keeping it in case direct playback of preloaded data is needed elsewhere. + // Or, it could be simplified to just return the blob if factory always handles play. + // For now, let's keep the playback logic but it might be unused by the factory flow. + console.log(`${this.name}: Playing preloaded audio...`); + const audioManager = this.getModule('audio-manager'); + if (audioManager && audioData) { + // This assumes audioManager.play handles Blobs + audioManager.play(audioData, callback); + } else { + console.error(`${this.name}: AudioManager not found or no audio data to play.`); + if (callback) callback(false, "Playback error"); + } + } + + /** + * Stops the currently playing audio. + */ + stop() { + console.log(`${this.name}: Stop requested.`); + const audioManager = this.getModule('audio-manager'); + if (audioManager) { + audioManager.stop(); + } + // Reset any internal state if needed + this.currentAudio = null; + } + + /** + * Speak the given text using the API. + * This method now primarily calls generateSpeechAudio and returns the result. + * Caching and playback are handled by TTSFactoryModule. + * @param {string} text - The text to speak. + * @returns {Promise} - A promise resolving to the audio Blob or null on failure. + */ + async speak(text) { + console.log(`${this.name}: speak called for text: ${text.substring(0, 30)}...`); + try { + // Generate audio data + const audioData = await this.generateSpeechAudio(text); + if (!audioData) { - console.error(`${this.name}: Failed to generate audio data for preloading`); + console.error(`${this.name}: Failed to generate audio for speak.`); return null; } - // Store in centralized TTSFactory cache - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory) { - ttsFactory.cacheSpeech(text, audioData); - } - - // Return audio data + // Return the Blob for the factory to handle return audioData; + } catch (error) { - console.error(`${this.name}: Preload speech error:`, error); + console.error(`${this.name}: Error in speak method:`, error); return null; } } - + /** - * Generate speech audio data - * @param {string} text - Text to generate speech for - * @returns {Promise} - Audio data (Blob) + * Preloads speech for the given text. + * Generates the audio data but does not play it. + * Returns the generated Blob for the factory to cache. + * @param {string} text - The text to preload. + * @returns {Promise} - A promise resolving to the audio Blob or null on failure. */ - async generateSpeechAudio(text) { - // Should be implemented by subclasses - return null; - } - - /** - * Speak text using preloaded audio - * @param {Object} preloadData - Preloaded audio data - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - speakPreloaded(preloadData, callback = null) { - if (!this.isReady || !this.available || !preloadData) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); - } - return false; - } - + async preloadSpeech(text) { + console.log(`${this.name}: preloadSpeech called for text: ${text.substring(0, 30)}...`); try { - // Stop any current audio - this.stop(); - - // Create Blob URL - const audioUrl = URL.createObjectURL(preloadData); - - // Create new audio element - const audio = new Audio(audioUrl); - - // Set up event handlers - audio.addEventListener('ended', () => { - // Clean up URL object - URL.revokeObjectURL(audioUrl); - - // Clear current audio reference - if (this.currentAudio === audio) { - this.currentAudio = null; - } - - // Dispatch completion event - this.dispatchEvent('tts:speak:complete', {}); - - if (callback) { - callback({ success: true }); - } - }, { once: true }); - - audio.addEventListener('error', (error) => { - console.error(`${this.name}: Playback error:`, error); - - // Clean up URL object - URL.revokeObjectURL(audioUrl); - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { error: error.message || 'Unknown error' }); - - if (callback) { - callback({ success: false, reason: 'playback_error', error }); - } - }, { once: true }); - - // Store reference to current audio - this.currentAudio = audio; - - // Play the audio - audio.play(); - - return true; + // Generate audio data using the main generation method + const audioData = await this.generateSpeechAudio(text); + + if (audioData) { + console.log(`${this.name}: Successfully preloaded speech (blob generated).`); + return audioData; // Return the Blob for the factory + } else { + console.error(`${this.name}: Failed to generate audio for preload.`); + return null; + } } catch (error) { - console.error(`${this.name}: Error playing preloaded audio:`, error); - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - error: error.message || 'Unknown error' - }); - - if (callback) { - setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0); - } - - return false; - } - } - - /** - * Speak text - * @param {string} text - Text to speak - * @param {Function} callback - Callback for when speech completes - * @returns {boolean} - Success status - */ - async speak(text, callback = null) { - if (!this.isReady || !this.available || !text) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); - } - return false; - } - - try { - // Process text for TTS - const processedText = this.preprocessText(text); - - // Check if already preloaded - const ttsFactory = this.getModule('tts-factory'); - if (ttsFactory && ttsFactory.isSpeechCached(text)) { - return this.speakPreloaded(ttsFactory.getCachedSpeech(text), callback); - } - - // Generate audio data - const audioData = await this.generateSpeechAudio(processedText); - - if (!audioData) { - if (callback) { - setTimeout(() => callback({ success: false, reason: 'generation_failed' }), 0); - } - return false; - } - - // Store in centralized TTSFactory cache - if (ttsFactory) { - ttsFactory.cacheSpeech(text, audioData); - } - - // Play the audio - return this.speakPreloaded(audioData, callback); - } catch (error) { - console.error(`${this.name}: Error generating speech:`, error); - - // Dispatch error event - this.dispatchEvent('tts:speak:error', { - text, - error: error.message || 'Unknown error' - }); - - if (callback) { - setTimeout(() => callback({ success: false, reason: 'generation_error', error }), 0); - } - - return false; + console.error(`${this.name}: Error during preloadSpeech:`, error); + return null; } } @@ -415,20 +387,6 @@ export class ApiTTSHandlerBase extends TTSHandler { return processed; } - /** - * Stop speaking - */ - stop() { - if (this.currentAudio) { - try { - this.currentAudio.pause(); - this.currentAudio = null; - } catch (error) { - console.error(`${this.name}: Error stopping speech:`, error); - } - } - } - /** * Check if TTS is available * @returns {boolean} - True if TTS is available @@ -485,9 +443,21 @@ export class ApiTTSHandlerBase extends TTSHandler { if (event && event.detail && event.detail.provider === this.id) { const newKey = event.detail.key || ''; + // Security check - never use a URL as an API key + if (newKey && newKey.startsWith('http')) { + console.error(`${this.name}: Received URL instead of API key, ignoring it`); + return; // Don't update API key + } + // Update API key this.apiKey = newKey; + // Save to preferences + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey); + } + // Update functionality status but don't make it unavailable // We want it to stay in the dropdown for configuration const wasFullyFunctional = this.available; diff --git a/public/js/audio-manager.js b/public/js/audio-manager.js index 2ad697c..40628f1 100644 --- a/public/js/audio-manager.js +++ b/public/js/audio-manager.js @@ -14,6 +14,9 @@ class AudioManagerModule extends BaseModule { this.masterVolume = 1.0; this.musicVolume = 1.0; this.sfxVolume = 1.0; + + // Add persistence-manager as a dependency + this.dependencies = ['persistence-manager']; } /** @@ -275,6 +278,97 @@ class AudioManagerModule extends BaseModule { }, 50); }); } + + /** + * Play speech audio from a Blob + * @param {Blob} audioBlob - The audio Blob to play + * @param {Object} options - Playback options + * @returns {Promise} - Resolves with success status + */ + async playSpeech(audioBlob, options = {}) { + if (!audioBlob || !(audioBlob instanceof Blob)) { + console.error('AudioManager: Invalid speech audio blob provided'); + return false; + } + + try { + // Create object URL from Blob + const audioUrl = URL.createObjectURL(audioBlob); + + // Stop any current non-looping audio + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio.currentTime = 0; + } + + // Create new audio element + const audio = new Audio(audioUrl); + + // Get speech volume from options or preferences + const persistenceManager = this.getModule('persistence-manager'); + let speechVolume = 1.0; + + if (persistenceManager) { + // Get speech volume from preferences (0.0 to 1.0) + speechVolume = persistenceManager.getPreference('tts', 'volume') || 1.0; + } + + // Override with options if provided + if (options.volume !== undefined) { + speechVolume = options.volume; + } + + // Apply master volume and speech volume + audio.volume = this.masterVolume * speechVolume; + + // Set up cleanup + audio.onended = () => { + URL.revokeObjectURL(audioUrl); + if (this.currentAudio === audio) { + this.currentAudio = null; + } + if (options.onComplete && typeof options.onComplete === 'function') { + options.onComplete(); + } + + // Dispatch event for speech completion + window.dispatchEvent(new CustomEvent('tts:speak-completed')); + }; + + // Handle errors + audio.onerror = (error) => { + console.error('AudioManager: Error playing speech audio:', error); + URL.revokeObjectURL(audioUrl); + if (this.currentAudio === audio) { + this.currentAudio = null; + } + if (options.onError && typeof options.onError === 'function') { + options.onError(error); + } + + // Dispatch event for speech error + window.dispatchEvent(new CustomEvent('tts:speak-error', { + detail: { error: error } + })); + }; + + // Store as current audio + this.currentAudio = audio; + + // Play the audio + await audio.play(); + return true; + } catch (error) { + console.error('AudioManager: Error setting up speech audio:', error); + + // Dispatch event for speech error + window.dispatchEvent(new CustomEvent('tts:speak-error', { + detail: { error: error } + })); + + return false; + } + } } // Create the singleton instance diff --git a/public/js/elevenlabs-tts-handler.js b/public/js/elevenlabs-tts-handler.js index a0963a8..f1db7cc 100644 --- a/public/js/elevenlabs-tts-handler.js +++ b/public/js/elevenlabs-tts-handler.js @@ -237,7 +237,9 @@ export class ElevenLabsTTSHandler extends ApiTTSHandlerBase { * @returns {Promise} - Audio data (Blob) */ async generateSpeechAudio(text) { - if (!text || !this.apiKey) { + // Don't attempt to call the API if no API key is set or text is empty + if (!text || !this.apiKey || this.apiKey.trim() === '') { + console.log('ElevenLabs TTS: No API key provided or empty text, skipping API call'); return null; } diff --git a/public/js/openai-tts-handler.js b/public/js/openai-tts-handler.js index fe54e1f..820fdd6 100644 --- a/public/js/openai-tts-handler.js +++ b/public/js/openai-tts-handler.js @@ -147,6 +147,11 @@ export class OpenAITTSHandler extends ApiTTSHandlerBase { } try { + // Log the actual values being used - don't truncate or mask for debugging + console.log('OpenAI TTS: Generating speech with:'); + console.log('- API Key:', this.apiKey); + console.log('- API URL:', this.apiBaseUrl); + // Create request payload const payload = { model: this.voiceOptions.model || 'tts-1', diff --git a/public/js/options-ui.js b/public/js/options-ui.js index 835b4f3..a39ea56 100644 --- a/public/js/options-ui.js +++ b/public/js/options-ui.js @@ -321,7 +321,7 @@ class OptionsUIModule extends BaseModule { elevenLabsApiUrl.addEventListener('change', (e) => { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { - persistenceManager.updatePreference('tts', 'elevenlabs_api_base_url', e.target.value); + persistenceManager.updatePreference('tts', 'elevenlabs_api_url', e.target.value); // Notify TTS system that API URL has changed document.dispatchEvent(new CustomEvent('tts:api:urlChanged', { @@ -377,7 +377,7 @@ class OptionsUIModule extends BaseModule { openaiApiUrl.addEventListener('change', (e) => { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { - persistenceManager.updatePreference('tts', 'openai_api_base_url', e.target.value); + persistenceManager.updatePreference('tts', 'openai_api_url', e.target.value); // Notify TTS system that API URL has changed document.dispatchEvent(new CustomEvent('tts:api:urlChanged', { @@ -854,7 +854,6 @@ class OptionsUIModule extends BaseModule { loadPreferences() { if (!this.persistenceManager || !this.elements) return; - // Wait for dependencies this.waitForDependencies().then(() => { const prefs = this.persistenceManager.getAllPreferences(); @@ -936,7 +935,7 @@ class OptionsUIModule extends BaseModule { // ElevenLabs API Base URL if (this.elements.elevenLabsApiUrl) { - this.elements.elevenLabsApiUrl.value = prefs.tts.elevenlabs_api_base_url; + this.elements.elevenLabsApiUrl.value = prefs.tts.elevenlabs_api_url; } // OpenAI API Key @@ -946,7 +945,7 @@ class OptionsUIModule extends BaseModule { // OpenAI API Base URL if (this.elements.openaiApiUrl) { - this.elements.openaiApiUrl.value = prefs.tts.openai_api_base_url; + this.elements.openaiApiUrl.value = prefs.tts.openai_api_url; } }); } @@ -1065,17 +1064,17 @@ class OptionsUIModule extends BaseModule { const elevenLabsApiKey = this.elements.elevenLabsApiKey.value; this.persistenceManager.updatePreference('tts', 'elevenlabs_api_key', elevenLabsApiKey); - // Save ElevenLabs API Base URL + // Save ElevenLabs API URL const elevenLabsApiUrl = this.elements.elevenLabsApiUrl.value; - this.persistenceManager.updatePreference('tts', 'elevenlabs_api_base_url', elevenLabsApiUrl); + this.persistenceManager.updatePreference('tts', 'elevenlabs_api_url', elevenLabsApiUrl); // Save OpenAI API Key const openaiApiKey = this.elements.openaiApiKey.value; this.persistenceManager.updatePreference('tts', 'openai_api_key', openaiApiKey); - // Save OpenAI API Base URL + // Save OpenAI API URL const openaiApiUrl = this.elements.openaiApiUrl.value; - this.persistenceManager.updatePreference('tts', 'openai_api_base_url', openaiApiUrl); + this.persistenceManager.updatePreference('tts', 'openai_api_url', openaiApiUrl); } setupEventListeners() { @@ -1157,40 +1156,41 @@ class OptionsUIModule extends BaseModule { // Set up ElevenLabs API URL if (this.elements.elevenLabsApiUrl) { const savedUrl = persistenceManager.getPreference('tts', 'elevenlabs_api_url'); + const defaultUrl = 'https://api.elevenlabs.io/v1'; + + // Always set the input value to the saved or default URL + this.elements.elevenLabsApiUrl.value = savedUrl || defaultUrl; + + // Save default to persistence if not already set if (!savedUrl) { - const defaultUrl = 'https://api.elevenlabs.io/v1'; console.log('Options UI: Setting default ElevenLabs API URL:', defaultUrl); - this.elements.elevenLabsApiUrl.value = defaultUrl; persistenceManager.updatePreference('tts', 'elevenlabs_api_url', defaultUrl); - - // Also dispatch the change event to notify the handler - window.dispatchEvent(new CustomEvent('tts:api:urlChanged', { - detail: { - provider: 'elevenlabs', - url: defaultUrl - } - })); } } // Set up OpenAI API URL if (this.elements.openaiApiUrl) { const savedUrl = persistenceManager.getPreference('tts', 'openai_api_url'); + const defaultUrl = 'https://api.openai.com/v1'; + + // Always set the input value to the saved or default URL + this.elements.openaiApiUrl.value = savedUrl || defaultUrl; + + // Save default to persistence only if not already set if (!savedUrl) { - const defaultUrl = 'https://api.openai.com/v1'; console.log('Options UI: Setting default OpenAI API URL:', defaultUrl); - this.elements.openaiApiUrl.value = defaultUrl; persistenceManager.updatePreference('tts', 'openai_api_url', defaultUrl); - - // Also dispatch the change event to notify the handler - window.dispatchEvent(new CustomEvent('tts:api:urlChanged', { - detail: { - provider: 'openai', - url: defaultUrl - } - })); } } + + // Make sure API keys are initialized if not already set + if (!persistenceManager.getPreference('tts', 'elevenlabs_api_key')) { + persistenceManager.updatePreference('tts', 'elevenlabs_api_key', ''); + } + + if (!persistenceManager.getPreference('tts', 'openai_api_key')) { + persistenceManager.updatePreference('tts', 'openai_api_key', ''); + } } } diff --git a/public/js/patterns/de.wasm b/public/js/patterns/de-de.wasm similarity index 100% rename from public/js/patterns/de.wasm rename to public/js/patterns/de-de.wasm diff --git a/public/js/persistence-manager.js b/public/js/persistence-manager.js index 2f14930..414884d 100644 --- a/public/js/persistence-manager.js +++ b/public/js/persistence-manager.js @@ -180,11 +180,6 @@ class PersistenceManagerModule extends BaseModule { // Use default preferences if none found this.preferences = JSON.parse(JSON.stringify(this.defaultPreferences)); - // Try to set locale based on browser language - const browserLocale = navigator.language.toLowerCase(); - if (browserLocale) { - this.preferences.app.locale = browserLocale; - } } return this.preferences; diff --git a/public/js/tts-factory.js b/public/js/tts-factory.js index 0eefe90..2721c98 100644 --- a/public/js/tts-factory.js +++ b/public/js/tts-factory.js @@ -23,12 +23,18 @@ class TTSFactoryModule extends BaseModule { this.ttsAvailable = false; this.speed = 1; // Default speed - // LRU Cache for preloaded speech - this.audioCache = new Map(); - this.maxCacheSize = 20; // Maximum number of cached items - this.cacheHits = 0; - this.cacheMisses = 0; - + // 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') { @@ -79,10 +85,17 @@ class TTSFactoryModule extends BaseModule { 'generateSpeechHash', 'speakPreloaded', 'getCachedSpeech', - 'addToCache', 'manageCacheSize', 'cacheSpeech', - 'isSpeechCached' + 'isSpeechCached', + '_initializeDB', + '_getDBItem', + '_putDBItem', + '_deleteDBItem', + '_calculateTotalCacheSize', + '_getAllDBItemsSortedByAccess', + '_getDBItemOnly', + '_generateHash' ]); } @@ -110,79 +123,62 @@ class TTSFactoryModule extends BaseModule { this.initStatus[id] = false; } - // Register all available handlers (this will overwrite any existing handlers) - console.log('TTS Factory: Registering all handlers'); + 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.registerHandler('kokoro', new KokoroHandler()); - console.log('TTS Factory: Registered handlers:', Object.keys(this.handlers)); - this.reportProgress(30, "Registered TTS handlers"); + this.reportProgress(30, "Initializing handlers"); - // Initialize all handlers in parallel for efficiency - const initPromises = []; - for (const id of Object.keys(this.handlers)) { - console.log(`TTS Factory: Initializing handler ${id}`); - initPromises.push(this.initializeHandler(id).then(success => { - console.log(`TTS Factory: Handler ${id} initialization ${success ? 'succeeded' : 'failed'}`); - return { id, success }; - })); - } + // Initialize all handlers in parallel + const initPromises = Object.keys(this.handlers).map(id => this.initializeHandler(id)); + await Promise.all(initPromises); - // Wait for all handlers to initialize - const results = await Promise.all(initPromises); - console.log('TTS Factory: All handler initialization results:', results); + this.reportProgress(60, "All handlers initialized"); - // Get user preferences - const ttsEnabled = this.getPreference('tts', 'enabled', false); - let preferredProvider = this.getPreference('tts', 'provider', ''); + // Get TTS preferences + const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false); + const preferredProvider = persistenceManager.getPreference('tts', 'provider', 'none'); - // Default to browser if no provider is set - if (!preferredProvider || preferredProvider === 'none') { - preferredProvider = 'browser'; - persistenceManager.updatePreference('tts', 'provider', 'browser'); - } - - console.log(`TTS Factory: User preferences - enabled: ${ttsEnabled}, provider: ${preferredProvider}`); - - // Initialize handlers based on preferences - let initSuccess = false; + 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) { - // Try to initialize preferred handler first - this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`); - initSuccess = this.initStatus[preferredProvider] || false; + // Determine fallback order - Kokoro -> Browser -> None (API requires manual config) + const fallbackOrder = ['kokoro', 'browser']; - if (initSuccess) { - this.setActiveHandler(preferredProvider); - } else { - // If preferred handler failed, try alternatives based on priority: Kokoro -> Browser -> None - console.warn(`Failed to initialize preferred TTS handler: ${preferredProvider}, trying alternatives`); + // 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'); - // Try Kokoro TTS as fallback if not already tried - if (preferredProvider !== 'kokoro' && this.initStatus.kokoro) { - this.reportProgress(60, "Using Kokoro TTS as fallback"); - this.setActiveHandler('kokoro'); - // Update preference to Kokoro since it worked - persistenceManager.updatePreference('tts', 'provider', 'kokoro'); - initSuccess = true; - } - // Try Browser TTS as fallback if not already tried - else if (preferredProvider !== 'browser' && this.initStatus.browser) { - this.reportProgress(70, "Using Browser TTS as fallback"); - this.setActiveHandler('browser'); - // Update preference to Browser since it worked - persistenceManager.updatePreference('tts', 'provider', 'browser'); - initSuccess = true; - } - else { - // If all failed, set to none but don't disable TTS entirely - // This allows configuring API-based TTS later - this.reportProgress(80, "No working TTS handlers found"); - persistenceManager.updatePreference('tts', 'provider', 'none'); + 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 @@ -372,10 +368,121 @@ class TTSFactoryModule extends BaseModule { 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 { - return await this.handlers[this.activeHandler].speak(text, options); + // 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("Error speaking text:", 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; } } @@ -555,12 +662,12 @@ class TTSFactoryModule extends BaseModule { const hash = await this.generateSpeechHash(text); // Check if we already have this audio in cache - const cachedData = this.getCachedSpeech(hash); + 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.audioCache.delete(hash); + // this.audioCache.set(hash, cachedData); this.cacheHits++; return cachedData; } @@ -574,8 +681,8 @@ class TTSFactoryModule extends BaseModule { // Cache the generated speech data if (preloadData) { - this.addToCache(hash, preloadData); - console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.audioCache.size}/${this.maxCacheSize})`); + await this.cacheSpeech(hash, preloadData); + console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`); } return preloadData; @@ -655,73 +762,462 @@ class TTSFactoryModule extends BaseModule { /** * Get cached speech data * @param {string} hash - Hash of the speech data - * @returns {Object|null} - Cached speech data or null if not found + * @returns {Promise} - Cached speech data or null if not found */ - getCachedSpeech(hash) { - if (!this.audioCache || !this.audioCache.has(hash)) return null; - return this.audioCache.get(hash); + 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 {Object} data - Speech data to cache + * @param {Blob} audioData - The audio data to cache + * @returns {Promise} */ - addToCache(hash, data) { - if (!this.audioCache) this.audioCache = new Map(); - this.audioCache.set(hash, data); - this.cacheMisses++; - - // Manage cache size - this.manageCacheSize(); - } - - /** - * Manage cache size - */ - manageCacheSize() { - if (!this.audioCache) return; - - // Check if cache size exceeds the maximum allowed - if (this.audioCache.size > this.maxCacheSize) { - // Remove the oldest item from the cache - const oldestKey = this.audioCache.keys().next().value; - this.audioCache.delete(oldestKey); + 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 } } /** - * Generate a hash for a speech request - * @param {string} text - Text to generate hash for - * @returns {Promise} - Hash value + * Manages the cache size, ensuring it doesn't exceed the limit using LRU. + * @returns {Promise} */ - async generateSpeechHash(text) { - // For now, just use the text as the hash - // In a more complex implementation, you could include voice ID and other parameters - // You could also use a proper hashing function - return `${this.activeHandler}-${text}`; + 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 + } } /** - * Check if speech is cached by text - * @param {string} text - Text to check - * @returns {boolean} - True if cached + * 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); - return this.audioCache && this.audioCache.has(hash); + + 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; + } } /** - * Cache speech data with text as key - * @param {string} text - Text used for the speech - * @param {Object} audioData - The audio data to cache + * Opens and initializes the IndexedDB database. */ - async cacheSpeech(text, audioData) { - const hash = await this.generateSpeechHash(text); - this.addToCache(hash, audioData); + 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 */ diff --git a/public/js/tts-handler.js b/public/js/tts-handler.js index 8f462ad..314eee5 100644 --- a/public/js/tts-handler.js +++ b/public/js/tts-handler.js @@ -145,4 +145,18 @@ export class TTSHandler { } }); } + + /** + * Get a unique identifier for the current voice configuration + * Used for caching purposes + * @returns {string} - Unique identifier for current voice + */ + getCurrentVoiceIdentifier() { + // Default implementation uses voice ID and rate/speed + const voiceId = this.voiceOptions.voice || 'default'; + const rate = this.voiceOptions.rate || this.voiceOptions.speed || 1.0; + + // Return a string that uniquely identifies this voice configuration + return `${voiceId}_${rate}`; + } }