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