1250 lines
49 KiB
JavaScript
1250 lines
49 KiB
JavaScript
/**
|
|
* TTS Factory Module
|
|
* Manages TTS handler instances
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
import { moduleRegistry } from './module-registry.js';
|
|
import { BrowserTTSHandler } from './browser-tts-handler.js';
|
|
import { KokoroHandler } from './kokoro-handler.js';
|
|
import { ElevenLabsTTSHandler } from './elevenlabs-tts-handler.js';
|
|
import { OpenAITTSHandler } from './openai-tts-handler.js';
|
|
|
|
class TTSFactoryModule extends BaseModule {
|
|
/**
|
|
* Create a new TTS factory
|
|
*/
|
|
constructor() {
|
|
super('tts-factory', 'TTS Factory');
|
|
|
|
this.dependencies = ['persistence-manager', 'localization'];
|
|
this.handlers = {};
|
|
this.initStatus = {};
|
|
this.activeHandler = null;
|
|
this.ttsAvailable = false;
|
|
this.speed = 1; // Default speed
|
|
|
|
// IndexedDB Cache Configuration
|
|
this.db = null; // Will hold the DB connection
|
|
this.dbName = 'ttsAudioCacheDB';
|
|
this.storeName = 'audioCacheStore';
|
|
this.dbVersion = 1;
|
|
this.currentCacheSize = 0; // Track current size in bytes
|
|
this.maxCacheSizeBytes = 1 * 1024 * 1024 * 1024; // 1 GB limit
|
|
this.cacheInitialized = false;
|
|
|
|
// Cache status indicator (could be used in UI later)
|
|
this.cacheStatus = 'initializing'; // initializing, ready, error
|
|
|
|
// Listen for kokoro:ready event
|
|
document.addEventListener('kokoro:ready', (event) => {
|
|
if (event.detail && typeof event.detail.success === 'boolean') {
|
|
console.log('TTS Factory: Received kokoro:ready event with success =', event.detail.success);
|
|
this.initStatus['kokoro'] = event.detail.success;
|
|
|
|
// If this is the current active handler or we don't have an active handler yet,
|
|
// try to activate Kokoro if it's now ready
|
|
if ((this.activeHandler === 'kokoro' || !this.activeHandler) && event.detail.success) {
|
|
// Only attempt to set active handler if TTS is enabled
|
|
const ttsEnabled = this.getPreference('tts', 'enabled', false);
|
|
if (ttsEnabled) {
|
|
this.setActiveHandler('kokoro');
|
|
}
|
|
}
|
|
|
|
// Update overall TTS availability
|
|
this.updateTTSAvailability();
|
|
}
|
|
});
|
|
|
|
// Listen for handler availability changes
|
|
document.addEventListener('tts:handler:availabilityChanged', (event) => {
|
|
if (event && event.detail) {
|
|
const { handlerId, available } = event.detail;
|
|
console.log(`TTS Factory: Handler ${handlerId} availability changed to ${available}`);
|
|
this.updateTTSAvailability();
|
|
}
|
|
});
|
|
|
|
// Bind methods
|
|
this.bindMethods([
|
|
'registerHandler',
|
|
'initializeHandler',
|
|
'getHandler',
|
|
'setActiveHandler',
|
|
'getActiveHandler',
|
|
'getAvailableHandlers',
|
|
'speak',
|
|
'stop',
|
|
'pause',
|
|
'resume',
|
|
'getVoices',
|
|
'getPreference',
|
|
'isSpeaking',
|
|
'configure',
|
|
'preloadSpeech',
|
|
'generateSpeechHash',
|
|
'speakPreloaded',
|
|
'getCachedSpeech',
|
|
'manageCacheSize',
|
|
'cacheSpeech',
|
|
'isSpeechCached',
|
|
'_initializeDB',
|
|
'_getDBItem',
|
|
'_putDBItem',
|
|
'_deleteDBItem',
|
|
'_calculateTotalCacheSize',
|
|
'_getAllDBItemsSortedByAccess',
|
|
'_getDBItemOnly',
|
|
'_generateHash'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize the module
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, "Initializing TTS factory");
|
|
|
|
// Get dependencies
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
const localization = this.getModule('localization');
|
|
|
|
if (!persistenceManager || !localization) {
|
|
console.error("TTS Factory: Required dependencies not found");
|
|
this.reportProgress(100, "TTS factory failed - missing dependencies");
|
|
return false;
|
|
}
|
|
|
|
// Reset any previous state
|
|
this.initStatus = {};
|
|
for (const id in this.handlers) {
|
|
this.initStatus[id] = false;
|
|
}
|
|
|
|
this.reportProgress(20, "Registering TTS handlers");
|
|
|
|
// Register handlers
|
|
// Following correct fallback order: Kokoro -> Browser -> None (API requires manual config)
|
|
this.registerHandler('kokoro', new KokoroHandler());
|
|
this.registerHandler('browser', new BrowserTTSHandler());
|
|
this.registerHandler('elevenlabs', new ElevenLabsTTSHandler());
|
|
this.registerHandler('openai', new OpenAITTSHandler());
|
|
|
|
this.reportProgress(30, "Initializing handlers");
|
|
|
|
// Initialize all handlers in parallel
|
|
const initPromises = Object.keys(this.handlers).map(id => this.initializeHandler(id));
|
|
await Promise.all(initPromises);
|
|
|
|
this.reportProgress(60, "All handlers initialized");
|
|
|
|
// Get TTS preferences
|
|
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
|
|
const preferredProvider = persistenceManager.getPreference('tts', 'provider', 'none');
|
|
|
|
console.log(`TTS Factory: TTS enabled: ${ttsEnabled}, preferred provider: ${preferredProvider}`);
|
|
this.reportProgress(70, `TTS preferences loaded: enabled=${ttsEnabled}, provider=${preferredProvider}`);
|
|
|
|
// Set active handler based on preferences
|
|
if (ttsEnabled) {
|
|
// Determine fallback order - Kokoro -> Browser -> None (API requires manual config)
|
|
const fallbackOrder = ['kokoro', 'browser'];
|
|
|
|
// Try to set the preferred provider first
|
|
let success = false;
|
|
if (preferredProvider && preferredProvider !== 'none') {
|
|
success = await this.setActiveHandler(preferredProvider);
|
|
}
|
|
|
|
// If preferred provider failed or wasn't specified, try the fallback order
|
|
if (!success) {
|
|
console.log('TTS Factory: Preferred provider unavailable, trying fallbacks');
|
|
|
|
for (const id of fallbackOrder) {
|
|
if (this.handlers[id] && this.initStatus[id]) {
|
|
console.log(`TTS Factory: Trying fallback provider: ${id}`);
|
|
success = await this.setActiveHandler(id);
|
|
if (success) {
|
|
console.log(`TTS Factory: Using fallback provider: ${id}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!success) {
|
|
console.warn('TTS Factory: No viable TTS provider found');
|
|
}
|
|
} else {
|
|
console.log('TTS Factory: TTS is disabled in preferences');
|
|
}
|
|
|
|
// Determine overall TTS availability
|
|
// Any handler that's initialized should count towards availability
|
|
this.ttsAvailable = Object.values(this.initStatus).some(status => status === true);
|
|
|
|
console.log('TTS Factory: Overall TTS availability:', this.ttsAvailable);
|
|
console.log('TTS Factory: Handler status:', this.initStatus);
|
|
|
|
// Dispatch TTS availability event
|
|
window.dispatchEvent(new CustomEvent('tts:availability', {
|
|
detail: { available: this.ttsAvailable }
|
|
}));
|
|
|
|
this.reportProgress(100, "TTS factory initialized");
|
|
return true; // TTS is optional, so always return true
|
|
} catch (error) {
|
|
console.error("TTS Factory: Error during initialization:", error);
|
|
this.reportProgress(100, "TTS factory failed");
|
|
return true; // TTS is optional, so always return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a TTS handler
|
|
* @param {string} id - Handler ID
|
|
* @param {Object} handler - TTS handler instance
|
|
*/
|
|
registerHandler(id, handler) {
|
|
if (!id || !handler) return;
|
|
this.handlers[id] = handler;
|
|
}
|
|
|
|
/**
|
|
* Initialize a specific TTS handler
|
|
* @param {string} id - Handler ID to initialize
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initializeHandler(id) {
|
|
if (!this.handlers[id]) {
|
|
console.error(`TTS Factory: Handler ${id} not found`);
|
|
return false;
|
|
}
|
|
|
|
console.log(`TTS Factory: Initializing handler ${id}`);
|
|
const progressCallback = (progress, message) => {
|
|
const mappedProgress = (progress / 100) || 0;
|
|
console.log(`TTS Factory: Handler ${id} progress: ${progress}%, ${message}`);
|
|
this.reportProgress(50 + Math.round(mappedProgress * 40), `Initializing ${id}: ${message}`);
|
|
};
|
|
|
|
try {
|
|
// Initialize the handler with progress callback
|
|
const success = await this.handlers[id].initialize(progressCallback);
|
|
this.initStatus[id] = success;
|
|
|
|
if (success) {
|
|
console.log(`TTS Factory: Handler ${id} initialized successfully`);
|
|
// Force getVoices() to ensure voices are loaded
|
|
const voices = this.handlers[id].getVoices();
|
|
console.log(`TTS Factory: Handler ${id} has ${voices ? voices.length : 0} voices available after initialization`);
|
|
} else {
|
|
console.warn(`TTS Factory: Handler ${id} initialization failed`);
|
|
}
|
|
|
|
return success;
|
|
} catch (error) {
|
|
console.error(`TTS Factory: Error initializing handler ${id}:`, error);
|
|
this.initStatus[id] = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a TTS handler by ID
|
|
* @param {string} id - Handler ID
|
|
* @returns {Object|null} - TTS handler instance or null if not found
|
|
*/
|
|
getHandler(id) {
|
|
if (!id || !this.handlers[id]) return null;
|
|
return this.handlers[id];
|
|
}
|
|
|
|
/**
|
|
* Set the active TTS handler
|
|
* @param {string} id - Handler ID
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
setActiveHandler(id) {
|
|
// If 'none' is passed, disable TTS
|
|
if (id === 'none') {
|
|
this.activeHandler = null;
|
|
this.ttsAvailable = false;
|
|
|
|
// Notify about TTS availability change
|
|
document.dispatchEvent(new CustomEvent('tts:availability', {
|
|
detail: { available: false }
|
|
}));
|
|
|
|
// Notify about handler change
|
|
document.dispatchEvent(new CustomEvent('tts:handlerChanged', {
|
|
detail: { handlerId: 'none' }
|
|
}));
|
|
|
|
console.log('TTS Factory: TTS disabled');
|
|
return true;
|
|
}
|
|
|
|
// Check if the handler exists
|
|
if (!this.handlers[id]) {
|
|
console.error(`TTS Factory: Handler not found: ${id}`);
|
|
return false;
|
|
}
|
|
|
|
if (!this.initStatus[id]) {
|
|
console.error(`TTS Factory: Handler not initialized: ${id}`);
|
|
return false;
|
|
}
|
|
|
|
this.activeHandler = id;
|
|
|
|
// Update TTS availability state
|
|
this.ttsAvailable = true;
|
|
|
|
// Notify about TTS availability change
|
|
document.dispatchEvent(new CustomEvent('tts:availability', {
|
|
detail: { available: true }
|
|
}));
|
|
|
|
// Notify about handler change
|
|
document.dispatchEvent(new CustomEvent('tts:handlerChanged', {
|
|
detail: { handlerId: id }
|
|
}));
|
|
|
|
console.log(`TTS Factory: Active handler set to ${id}`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get the active TTS handler
|
|
* @returns {Object|null} - Active TTS handler instance or null if none active
|
|
*/
|
|
getActiveHandler() {
|
|
if (!this.activeHandler) return null;
|
|
return this.handlers[this.activeHandler];
|
|
}
|
|
|
|
/**
|
|
* Get available TTS handlers
|
|
* @returns {Array} - Array of handler objects
|
|
*/
|
|
getAvailableHandlers() {
|
|
const availableHandlers = [];
|
|
|
|
// Always show all initialized handlers in the options dropdown,
|
|
// regardless of availability status. This ensures API handlers are configurable
|
|
// even when the API key is not set.
|
|
for (const id in this.handlers) {
|
|
// Only include handlers that have been initialized
|
|
if (this.handlers[id] && this.initStatus[id]) {
|
|
console.log(`TTS Factory: Handler ${id} is initialized, adding to available handlers list`);
|
|
availableHandlers.push({
|
|
id: id,
|
|
handler: this.handlers[id]
|
|
});
|
|
}
|
|
}
|
|
|
|
if (availableHandlers.length === 0) {
|
|
console.warn('TTS Factory: No available handlers found - something is wrong!');
|
|
} else {
|
|
console.log(`TTS Factory: Found ${availableHandlers.length} available handlers`);
|
|
}
|
|
|
|
return availableHandlers;
|
|
}
|
|
|
|
/**
|
|
* Speak text using the active TTS handler
|
|
* @param {string} text - Text to speak
|
|
* @param {Object} options - TTS options
|
|
* @returns {Promise<boolean>} - Success status
|
|
*/
|
|
async speak(text, options = {}) {
|
|
if (!this.activeHandler) {
|
|
console.warn("No active TTS handler");
|
|
return false;
|
|
}
|
|
|
|
const handler = this.handlers[this.activeHandler];
|
|
if (!handler || !handler.isReady) {
|
|
console.warn(`TTS handler ${this.activeHandler} is not ready`);
|
|
return false;
|
|
}
|
|
|
|
// Special case for browser TTS - don't use caching
|
|
if (this.activeHandler === 'browser') {
|
|
return handler.speak(text, options);
|
|
}
|
|
|
|
// For other handlers (API, Kokoro), use caching
|
|
const hash = await this._generateHash(text + handler.getCurrentVoiceIdentifier());
|
|
let audioData = null;
|
|
|
|
try {
|
|
// 1. Check Cache
|
|
console.log(`TTSFactory: Checking cache for hash ${hash}`);
|
|
audioData = await this.getCachedSpeech(hash);
|
|
|
|
if (audioData) {
|
|
console.log(`TTSFactory: Found cached audio for hash ${hash}`);
|
|
} else {
|
|
// 2. Generate Speech if not in cache
|
|
console.log(`TTSFactory: Generating speech for hash ${hash}`);
|
|
audioData = await handler.speak(text);
|
|
|
|
if (!audioData) {
|
|
throw new Error(`Failed to generate speech for text: ${text.substring(0, 20)}...`);
|
|
}
|
|
|
|
// 3. Cache the Result
|
|
await this.cacheSpeech(hash, audioData);
|
|
}
|
|
|
|
// 4. Play Audio (either cached or newly generated)
|
|
if (audioData) {
|
|
const audioManager = this.getModule('audio-manager');
|
|
if (!audioManager) throw new Error('AudioManager module not found');
|
|
|
|
// Use the new playSpeech method that handles speech audio blobs
|
|
await audioManager.playSpeech(audioData, options); // Pass original options
|
|
console.log(`TTSFactory: Playback initiated for hash ${hash}`);
|
|
return true;
|
|
} else {
|
|
throw new Error('No audio data available to play after cache check and generation.');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`TTSFactory: Error during speak process for hash ${hash}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preload speech audio for given text using the active handler.
|
|
* Handles caching automatically.
|
|
* @param {string} text - Text to synthesize.
|
|
* @param {number} [priority=5] - Priority for preloading.
|
|
* @returns {Promise<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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
stop() {
|
|
if (!this.activeHandler) return false;
|
|
|
|
try {
|
|
return this.handlers[this.activeHandler].stop();
|
|
} catch (error) {
|
|
console.error("Error stopping TTS:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pause speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
pause() {
|
|
if (!this.activeHandler) return false;
|
|
|
|
try {
|
|
return this.handlers[this.activeHandler].pause();
|
|
} catch (error) {
|
|
console.error("Error pausing TTS:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
resume() {
|
|
if (!this.activeHandler) return false;
|
|
|
|
try {
|
|
return this.handlers[this.activeHandler].resume();
|
|
} catch (error) {
|
|
console.error("Error resuming TTS:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get voices from the active handler
|
|
* @returns {Array} - Array of voices
|
|
*/
|
|
getVoices() {
|
|
// Get the active handler
|
|
const handler = this.getActiveHandler();
|
|
|
|
// Check if we have an active handler
|
|
if (!handler) {
|
|
console.log('TTS Factory: No active handler, returning empty voices array');
|
|
return [];
|
|
}
|
|
|
|
// Get voices from the active handler
|
|
const voices = handler.getVoices();
|
|
console.log(`TTS Factory: Retrieved ${voices ? voices.length : 0} voices from ${this.activeHandler}`);
|
|
|
|
// Check if we have any voices
|
|
if (!voices || voices.length === 0) {
|
|
console.warn(`TTS Factory: No voices retrieved from ${this.activeHandler} handler`);
|
|
return [];
|
|
}
|
|
|
|
// Return voices
|
|
return voices;
|
|
}
|
|
|
|
/**
|
|
* Get a preference from the persistence manager
|
|
* @param {string} category - Preference category
|
|
* @param {string} key - Preference key
|
|
* @param {*} defaultValue - Default value if preference doesn't exist
|
|
* @returns {*} - Preference value
|
|
*/
|
|
getPreference(category, key, defaultValue) {
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (persistenceManager) {
|
|
return persistenceManager.getPreference(category, key, defaultValue);
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Check if any TTS handler is currently speaking
|
|
* @returns {boolean} - True if speaking, false otherwise
|
|
*/
|
|
isSpeaking() {
|
|
if (!this.activeHandler || !this.handlers[this.activeHandler]) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return this.handlers[this.activeHandler].isSpeaking();
|
|
} catch (error) {
|
|
console.error("Error checking speaking status:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update overall TTS availability
|
|
*/
|
|
updateTTSAvailability() {
|
|
this.ttsAvailable = this.initStatus.kokoro || this.initStatus.browser;
|
|
|
|
// Dispatch TTS availability event
|
|
window.dispatchEvent(new CustomEvent('tts:availability', {
|
|
detail: { available: this.ttsAvailable }
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Configure TTS settings for all handlers
|
|
* @param {Object} options - TTS options
|
|
* @param {number} [options.speed] - Normalized speech rate (0-1 range)
|
|
*/
|
|
configure(options = {}) {
|
|
// If speed is provided, convert the normalized speed (0-1) to the appropriate scale for each handler
|
|
if (typeof options.speed === 'number') {
|
|
const normalizedSpeed = Math.max(0, Math.min(1, options.speed));
|
|
|
|
// Scale for each handler type
|
|
for (const id in this.handlers) {
|
|
// Ensure the handler exists and has the setVoiceOptions method
|
|
if (this.handlers[id] && typeof this.handlers[id].setVoiceOptions === 'function') {
|
|
let scaledOptions = {};
|
|
|
|
// Scale the speed value appropriately for each handler type
|
|
if (id === 'browser') {
|
|
// Browser TTS uses rate from 0.1 to 2.0
|
|
scaledOptions.rate = 0.1 + (normalizedSpeed * 1.9);
|
|
} else if (id === 'kokoro') {
|
|
// Kokoro uses rate from 0.5 to 1.5
|
|
scaledOptions.rate = 0.5 + (normalizedSpeed);
|
|
} else if (id === 'elevenlabs' || id === 'openai') {
|
|
// ElevenLabs and OpenAI use speed from 0.5 to 2.0
|
|
scaledOptions.speed = 0.5 + (normalizedSpeed * 1.5);
|
|
}
|
|
|
|
// Apply the scaled options to the handler
|
|
this.handlers[id].setVoiceOptions(scaledOptions);
|
|
}
|
|
}
|
|
|
|
// Store the normalized value
|
|
this.speed = normalizedSpeed;
|
|
|
|
console.log(`TTS Factory: Speed set to ${normalizedSpeed} (normalized), ${Math.round(normalizedSpeed * 100)}/100`);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Preload speech for a text
|
|
* @param {string} text - Text to preload
|
|
* @returns {Promise<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 already have this audio in cache
|
|
const cachedData = await this.getCachedSpeech(hash);
|
|
if (cachedData) {
|
|
console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`);
|
|
// Move this item to the end of the Map to mark it as most recently used
|
|
// this.audioCache.delete(hash);
|
|
// this.audioCache.set(hash, cachedData);
|
|
this.cacheHits++;
|
|
return cachedData;
|
|
}
|
|
|
|
// Cache miss - need to generate new speech data
|
|
this.cacheMisses++;
|
|
|
|
// If the handler has a preloadSpeech method, use it
|
|
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
|
|
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
|
|
|
|
// Cache the generated speech data
|
|
if (preloadData) {
|
|
await this.cacheSpeech(hash, preloadData);
|
|
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
|
|
}
|
|
|
|
return preloadData;
|
|
} else {
|
|
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error("TTS Factory: Error preloading speech:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique hash for a speech request
|
|
* @param {string} text - Text to generate hash for
|
|
* @returns {Promise<string>} - Hash string
|
|
*/
|
|
async generateSpeechHash(text) {
|
|
if (!this.activeHandler) return null;
|
|
|
|
// Get voice ID and other parameters
|
|
const handler = this.handlers[this.activeHandler];
|
|
const handlerId = this.activeHandler;
|
|
const voiceId = handler.voiceOptions?.voice?.id || 'default';
|
|
const speed = this.speed;
|
|
|
|
// Create a string to hash
|
|
const dataToHash = `${handlerId}_${voiceId}_${speed}_${text}`;
|
|
|
|
// Use SubtleCrypto to create a SHA-256 hash if available
|
|
try {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(dataToHash);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
|
|
// Convert to hex string
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
|
|
return hashHex;
|
|
} catch (error) {
|
|
// Fallback to simple string hash if SubtleCrypto is not available
|
|
console.warn('TTS Factory: Unable to generate crypto hash, using fallback', error);
|
|
|
|
let hash = 0;
|
|
for (let i = 0; i < dataToHash.length; i++) {
|
|
const char = dataToHash.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return Math.abs(hash).toString(16);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Speak using preloaded speech data
|
|
* @param {Object} preloadData - Preloaded speech data
|
|
* @param {Object} options - Speech options
|
|
* @returns {Promise<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<Blob|null>} - Cached speech data or null if not found
|
|
*/
|
|
async getCachedSpeech(hash) {
|
|
if (!this.db || this.cacheStatus !== 'ready') {
|
|
console.warn("IndexedDB not ready, cannot get item.");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const data = await this._getDBItem(hash);
|
|
if (data) {
|
|
console.log(`TTS Factory: Cache hit for hash ${hash}`);
|
|
} else {
|
|
console.log(`TTS Factory: Cache miss for hash ${hash}`);
|
|
}
|
|
return data;
|
|
} catch (error) {
|
|
console.error(`TTS Factory: Error getting cached speech for hash ${hash}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add speech data to the cache
|
|
* @param {string} hash - Hash of the speech data
|
|
* @param {Blob} audioData - The audio data to cache
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async cacheSpeech(hash, audioData) {
|
|
if (!this.db || this.cacheStatus !== 'ready') {
|
|
console.warn("IndexedDB not ready, cannot cache speech.");
|
|
return;
|
|
}
|
|
if (!(audioData instanceof Blob) || audioData.size === 0) {
|
|
console.warn("TTSFactory: Invalid audio data provided for caching.");
|
|
return;
|
|
}
|
|
|
|
const handler = this.getActiveHandler();
|
|
if (!handler) {
|
|
console.warn("TTSFactory: No active handler, cannot determine voice identifier for cache key.");
|
|
return;
|
|
}
|
|
|
|
const size = audioData.size;
|
|
const lastAccessed = Date.now();
|
|
const newItem = { hash, data: audioData, size, lastAccessed };
|
|
|
|
try {
|
|
// Check if item already exists to correctly update cache size
|
|
const existingItem = await this._getDBItemOnly(hash); // Helper needed to get without updating timestamp
|
|
if (existingItem && typeof existingItem.size === 'number') {
|
|
this.currentCacheSize -= existingItem.size; // Subtract old size
|
|
}
|
|
|
|
await this._putDBItem(newItem);
|
|
this.currentCacheSize += size; // Add new size
|
|
console.log(`TTS Factory: Cached speech for hash ${hash}. New size: ${size}. Total cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
|
|
|
|
// Trigger size check asynchronously
|
|
this.manageCacheSize().catch(error => {
|
|
console.error("TTS Factory: Error during post-cache size management:", error);
|
|
});
|
|
} catch (error) {
|
|
console.error(`TTS Factory: Error caching speech for hash ${hash}:`, error);
|
|
// Attempt to revert cache size change if put failed?
|
|
// Might be complex, log and potentially mark cache as unhealthy
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manages the cache size, ensuring it doesn't exceed the limit using LRU.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async manageCacheSize() {
|
|
if (!this.db || this.cacheStatus !== 'ready') {
|
|
console.warn("TTSFactory: Cache DB not ready for size management.");
|
|
return;
|
|
}
|
|
|
|
let iterations = 0;
|
|
const maxIterations = 100; // Safety break to prevent infinite loops
|
|
|
|
try {
|
|
// Ensure currentCacheSize is up-to-date before starting eviction
|
|
// This is important especially on startup or if background writes happened
|
|
this.currentCacheSize = await this._calculateTotalCacheSize();
|
|
console.log(`TTS Factory: Recalculated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
|
|
|
|
while (this.currentCacheSize > this.maxCacheSizeBytes && iterations < maxIterations) {
|
|
iterations++;
|
|
console.log(`TTS Factory: Cache limit exceeded (${(this.currentCacheSize / (1024*1024)).toFixed(2)}MB > ${(this.maxCacheSizeBytes / (1024*1024)).toFixed(2)}MB). Evicting oldest entry.`);
|
|
|
|
const sortedItems = await this._getAllDBItemsSortedByAccess();
|
|
if (sortedItems.length === 0) {
|
|
console.warn("TTS Factory: Cache size exceeds limit, but no items found to evict.");
|
|
this.currentCacheSize = 0; // Reset size if store is empty
|
|
break; // Exit loop
|
|
}
|
|
|
|
const oldestItem = sortedItems[0];
|
|
console.log(`TTS Factory: Evicting item with hash ${oldestItem.hash}, size ${oldestItem.size}, lastAccessed ${new Date(oldestItem.lastAccessed).toISOString()}`);
|
|
|
|
await this._deleteDBItem(oldestItem.hash);
|
|
if (typeof oldestItem.size === 'number') {
|
|
this.currentCacheSize -= oldestItem.size;
|
|
} else {
|
|
// Size was invalid, recalculate total size for safety
|
|
console.warn(`TTS Factory: Evicted item ${oldestItem.hash} had invalid size. Recalculating total size.`);
|
|
this.currentCacheSize = await this._calculateTotalCacheSize();
|
|
}
|
|
console.log(`TTS Factory: New estimated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
|
|
}
|
|
|
|
if (iterations >= maxIterations) {
|
|
console.error("TTS Factory: Max iterations reached during cache eviction. Cache might still be oversized.");
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("TTS Factory: Error during cache size management:", error);
|
|
// Consider setting cache status to error or attempting recovery
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if speech for the given text is likely cached.
|
|
* @param {string} text - The original text.
|
|
* @returns {Promise<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<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
|
|
*/
|
|
dispose() {
|
|
// Stop any active TTS
|
|
if (this.activeHandler) {
|
|
this.handlers[this.activeHandler].stop();
|
|
}
|
|
|
|
// Dispose all handlers
|
|
for (const id in this.handlers) {
|
|
if (this.handlers[id].dispose) {
|
|
this.handlers[id].dispose();
|
|
}
|
|
}
|
|
|
|
// Clear handlers
|
|
this.handlers = {};
|
|
this.activeHandler = null;
|
|
}
|
|
}
|
|
|
|
// Create the singleton instance
|
|
const TTSFactory = new TTSFactoryModule();
|
|
|
|
// Register with the module registry
|
|
moduleRegistry.register(TTSFactory);
|
|
|
|
// Export the module
|
|
export { TTSFactory }; |