Reference: Semi broken tts before refactoring
This commit is contained in:
+150
-180
@@ -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<Object>} - 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<Blob|null>} - 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<Blob|null>} - 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<Object>} - 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<Blob|null>} - 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;
|
||||
|
||||
@@ -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<boolean>} - 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
|
||||
|
||||
@@ -237,7 +237,9 @@ export class ElevenLabsTTSHandler extends ApiTTSHandlerBase {
|
||||
* @returns {Promise<Object>} - 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+29
-29
@@ -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', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+614
-118
@@ -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<boolean>} - 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<Blob|null>} - 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<void>}
|
||||
*/
|
||||
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<string>} - Hash value
|
||||
* Manages the cache size, ensuring it doesn't exceed the limit using LRU.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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<boolean>}
|
||||
*/
|
||||
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<Blob|null>} - 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<number>} - 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<object>>} - 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<object|null>}
|
||||
*/
|
||||
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<string>} - 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
|
||||
*/
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user