Reference: Semi broken tts before refactoring

This commit is contained in:
2025-04-05 17:23:01 +00:00
parent e8eb93ae1b
commit e5a3016846
9 changed files with 909 additions and 333 deletions
+614 -118
View File
@@ -23,12 +23,18 @@ class TTSFactoryModule extends BaseModule {
this.ttsAvailable = false;
this.speed = 1; // Default speed
// LRU Cache for preloaded speech
this.audioCache = new Map();
this.maxCacheSize = 20; // Maximum number of cached items
this.cacheHits = 0;
this.cacheMisses = 0;
// IndexedDB Cache Configuration
this.db = null; // Will hold the DB connection
this.dbName = 'ttsAudioCacheDB';
this.storeName = 'audioCacheStore';
this.dbVersion = 1;
this.currentCacheSize = 0; // Track current size in bytes
this.maxCacheSizeBytes = 1 * 1024 * 1024 * 1024; // 1 GB limit
this.cacheInitialized = false;
// Cache status indicator (could be used in UI later)
this.cacheStatus = 'initializing'; // initializing, ready, error
// Listen for kokoro:ready event
document.addEventListener('kokoro:ready', (event) => {
if (event.detail && typeof event.detail.success === 'boolean') {
@@ -79,10 +85,17 @@ class TTSFactoryModule extends BaseModule {
'generateSpeechHash',
'speakPreloaded',
'getCachedSpeech',
'addToCache',
'manageCacheSize',
'cacheSpeech',
'isSpeechCached'
'isSpeechCached',
'_initializeDB',
'_getDBItem',
'_putDBItem',
'_deleteDBItem',
'_calculateTotalCacheSize',
'_getAllDBItemsSortedByAccess',
'_getDBItemOnly',
'_generateHash'
]);
}
@@ -110,79 +123,62 @@ class TTSFactoryModule extends BaseModule {
this.initStatus[id] = false;
}
// Register all available handlers (this will overwrite any existing handlers)
console.log('TTS Factory: Registering all handlers');
this.reportProgress(20, "Registering TTS handlers");
// Register handlers
// Following correct fallback order: Kokoro -> Browser -> None (API requires manual config)
this.registerHandler('kokoro', new KokoroHandler());
this.registerHandler('browser', new BrowserTTSHandler());
this.registerHandler('elevenlabs', new ElevenLabsTTSHandler());
this.registerHandler('openai', new OpenAITTSHandler());
this.registerHandler('kokoro', new KokoroHandler());
console.log('TTS Factory: Registered handlers:', Object.keys(this.handlers));
this.reportProgress(30, "Registered TTS handlers");
this.reportProgress(30, "Initializing handlers");
// Initialize all handlers in parallel for efficiency
const initPromises = [];
for (const id of Object.keys(this.handlers)) {
console.log(`TTS Factory: Initializing handler ${id}`);
initPromises.push(this.initializeHandler(id).then(success => {
console.log(`TTS Factory: Handler ${id} initialization ${success ? 'succeeded' : 'failed'}`);
return { id, success };
}));
}
// Initialize all handlers in parallel
const initPromises = Object.keys(this.handlers).map(id => this.initializeHandler(id));
await Promise.all(initPromises);
// Wait for all handlers to initialize
const results = await Promise.all(initPromises);
console.log('TTS Factory: All handler initialization results:', results);
this.reportProgress(60, "All handlers initialized");
// Get user preferences
const ttsEnabled = this.getPreference('tts', 'enabled', false);
let preferredProvider = this.getPreference('tts', 'provider', '');
// Get TTS preferences
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
const preferredProvider = persistenceManager.getPreference('tts', 'provider', 'none');
// Default to browser if no provider is set
if (!preferredProvider || preferredProvider === 'none') {
preferredProvider = 'browser';
persistenceManager.updatePreference('tts', 'provider', 'browser');
}
console.log(`TTS Factory: User preferences - enabled: ${ttsEnabled}, provider: ${preferredProvider}`);
// Initialize handlers based on preferences
let initSuccess = false;
console.log(`TTS Factory: TTS enabled: ${ttsEnabled}, preferred provider: ${preferredProvider}`);
this.reportProgress(70, `TTS preferences loaded: enabled=${ttsEnabled}, provider=${preferredProvider}`);
// Set active handler based on preferences
if (ttsEnabled) {
// Try to initialize preferred handler first
this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`);
initSuccess = this.initStatus[preferredProvider] || false;
// Determine fallback order - Kokoro -> Browser -> None (API requires manual config)
const fallbackOrder = ['kokoro', 'browser'];
if (initSuccess) {
this.setActiveHandler(preferredProvider);
} else {
// If preferred handler failed, try alternatives based on priority: Kokoro -> Browser -> None
console.warn(`Failed to initialize preferred TTS handler: ${preferredProvider}, trying alternatives`);
// Try to set the preferred provider first
let success = false;
if (preferredProvider && preferredProvider !== 'none') {
success = await this.setActiveHandler(preferredProvider);
}
// If preferred provider failed or wasn't specified, try the fallback order
if (!success) {
console.log('TTS Factory: Preferred provider unavailable, trying fallbacks');
// Try Kokoro TTS as fallback if not already tried
if (preferredProvider !== 'kokoro' && this.initStatus.kokoro) {
this.reportProgress(60, "Using Kokoro TTS as fallback");
this.setActiveHandler('kokoro');
// Update preference to Kokoro since it worked
persistenceManager.updatePreference('tts', 'provider', 'kokoro');
initSuccess = true;
}
// Try Browser TTS as fallback if not already tried
else if (preferredProvider !== 'browser' && this.initStatus.browser) {
this.reportProgress(70, "Using Browser TTS as fallback");
this.setActiveHandler('browser');
// Update preference to Browser since it worked
persistenceManager.updatePreference('tts', 'provider', 'browser');
initSuccess = true;
}
else {
// If all failed, set to none but don't disable TTS entirely
// This allows configuring API-based TTS later
this.reportProgress(80, "No working TTS handlers found");
persistenceManager.updatePreference('tts', 'provider', 'none');
for (const id of fallbackOrder) {
if (this.handlers[id] && this.initStatus[id]) {
console.log(`TTS Factory: Trying fallback provider: ${id}`);
success = await this.setActiveHandler(id);
if (success) {
console.log(`TTS Factory: Using fallback provider: ${id}`);
break;
}
}
}
}
if (!success) {
console.warn('TTS Factory: No viable TTS provider found');
}
} else {
console.log('TTS Factory: TTS is disabled in preferences');
}
// Determine overall TTS availability
@@ -372,10 +368,121 @@ class TTSFactoryModule extends BaseModule {
return false;
}
const handler = this.handlers[this.activeHandler];
if (!handler || !handler.isReady) {
console.warn(`TTS handler ${this.activeHandler} is not ready`);
return false;
}
// Special case for browser TTS - don't use caching
if (this.activeHandler === 'browser') {
return handler.speak(text, options);
}
// For other handlers (API, Kokoro), use caching
const hash = await this._generateHash(text + handler.getCurrentVoiceIdentifier());
let audioData = null;
try {
return await this.handlers[this.activeHandler].speak(text, options);
// 1. Check Cache
console.log(`TTSFactory: Checking cache for hash ${hash}`);
audioData = await this.getCachedSpeech(hash);
if (audioData) {
console.log(`TTSFactory: Found cached audio for hash ${hash}`);
} else {
// 2. Generate Speech if not in cache
console.log(`TTSFactory: Generating speech for hash ${hash}`);
audioData = await handler.speak(text);
if (!audioData) {
throw new Error(`Failed to generate speech for text: ${text.substring(0, 20)}...`);
}
// 3. Cache the Result
await this.cacheSpeech(hash, audioData);
}
// 4. Play Audio (either cached or newly generated)
if (audioData) {
const audioManager = this.getModule('audio-manager');
if (!audioManager) throw new Error('AudioManager module not found');
// Use the new playSpeech method that handles speech audio blobs
await audioManager.playSpeech(audioData, options); // Pass original options
console.log(`TTSFactory: Playback initiated for hash ${hash}`);
return true;
} else {
throw new Error('No audio data available to play after cache check and generation.');
}
} catch (error) {
console.error("Error speaking text:", error);
console.error(`TTSFactory: Error during speak process for hash ${hash}:`, error);
return false;
}
}
/**
* Preload speech audio for given text using the active handler.
* Handles caching automatically.
* @param {string} text - Text to synthesize.
* @param {number} [priority=5] - Priority for preloading.
* @returns {Promise<boolean>} - True if preload finished successfully (either generated or already cached).
*/
async preloadSpeech(text, priority = 5) {
if (!this.isAvailable || !this.activeHandler) {
return false; // Cannot preload if TTS is unavailable
}
const handler = this.handlers[this.activeHandler];
if (!handler || !handler.isReady) {
console.warn(`TTSFactory: Active handler (${this.activeHandler}) not ready for preload.`);
return false;
}
// Browser TTS uses Web Speech API directly and is not preloaded/cached here
if (this.activeHandler === 'browser') {
console.log("TTSFactory: Skipping preload for Browser TTS.");
return true; // Consider it 'preloaded' as it's always ready locally
}
// Check if the handler supports preloading at all
if (typeof handler.preloadSpeech !== 'function') {
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`);
return false; // Cannot fulfill preload request
}
const hash = await this._generateHash(text + handler.getCurrentVoiceIdentifier());
try {
// 1. Check Cache
console.log(`TTSFactory: Checking preload cache for hash: ${hash}`);
const cachedAudio = await this.getCachedSpeech(hash);
if (cachedAudio) {
console.log(`TTS Factory: Preload cache hit for hash ${hash}.`);
this.cacheHits = (this.cacheHits || 0) + 1;
return true; // Already cached
}
console.log(`TTSFactory: Preload cache miss for hash ${hash}. Requesting preload generation from handler: ${this.activeHandler}`);
this.cacheMisses = (this.cacheMisses || 0) + 1;
// 2. Generate Audio via Handler Preload
// Handler's preloadSpeech method should now return the Blob
const audioData = await handler.preloadSpeech(text, priority);
if (!audioData || !(audioData instanceof Blob)) {
console.warn(`TTSFactory: Handler ${this.activeHandler} preloadSpeech did not return valid audio Blob for hash ${hash}.`);
return false; // Preload failed if no data returned
}
console.log(`TTSFactory: Handler ${this.activeHandler} generated preload audio Blob.`);
// 3. Cache the Result
await this.cacheSpeech(hash, audioData);
return true; // Successfully preloaded and cached
} catch (error) {
console.error(`TTSFactory: Error during preloadSpeech for hash ${hash}:`, error);
return false;
}
}
@@ -555,12 +662,12 @@ class TTSFactoryModule extends BaseModule {
const hash = await this.generateSpeechHash(text);
// Check if we already have this audio in cache
const cachedData = this.getCachedSpeech(hash);
const cachedData = await this.getCachedSpeech(hash);
if (cachedData) {
console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`);
// Move this item to the end of the Map to mark it as most recently used
this.audioCache.delete(hash);
this.audioCache.set(hash, cachedData);
// this.audioCache.delete(hash);
// this.audioCache.set(hash, cachedData);
this.cacheHits++;
return cachedData;
}
@@ -574,8 +681,8 @@ class TTSFactoryModule extends BaseModule {
// Cache the generated speech data
if (preloadData) {
this.addToCache(hash, preloadData);
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.audioCache.size}/${this.maxCacheSize})`);
await this.cacheSpeech(hash, preloadData);
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`);
}
return preloadData;
@@ -655,73 +762,462 @@ class TTSFactoryModule extends BaseModule {
/**
* Get cached speech data
* @param {string} hash - Hash of the speech data
* @returns {Object|null} - Cached speech data or null if not found
* @returns {Promise<Blob|null>} - Cached speech data or null if not found
*/
getCachedSpeech(hash) {
if (!this.audioCache || !this.audioCache.has(hash)) return null;
return this.audioCache.get(hash);
async getCachedSpeech(hash) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot get item.");
return null;
}
try {
const data = await this._getDBItem(hash);
if (data) {
console.log(`TTS Factory: Cache hit for hash ${hash}`);
} else {
console.log(`TTS Factory: Cache miss for hash ${hash}`);
}
return data;
} catch (error) {
console.error(`TTS Factory: Error getting cached speech for hash ${hash}:`, error);
return null;
}
}
/**
* Add speech data to the cache
* @param {string} hash - Hash of the speech data
* @param {Object} data - Speech data to cache
* @param {Blob} audioData - The audio data to cache
* @returns {Promise<void>}
*/
addToCache(hash, data) {
if (!this.audioCache) this.audioCache = new Map();
this.audioCache.set(hash, data);
this.cacheMisses++;
// Manage cache size
this.manageCacheSize();
}
/**
* Manage cache size
*/
manageCacheSize() {
if (!this.audioCache) return;
// Check if cache size exceeds the maximum allowed
if (this.audioCache.size > this.maxCacheSize) {
// Remove the oldest item from the cache
const oldestKey = this.audioCache.keys().next().value;
this.audioCache.delete(oldestKey);
async cacheSpeech(hash, audioData) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot cache speech.");
return;
}
if (!(audioData instanceof Blob) || audioData.size === 0) {
console.warn("TTSFactory: Invalid audio data provided for caching.");
return;
}
const handler = this.getActiveHandler();
if (!handler) {
console.warn("TTSFactory: No active handler, cannot determine voice identifier for cache key.");
return;
}
const size = audioData.size;
const lastAccessed = Date.now();
const newItem = { hash, data: audioData, size, lastAccessed };
try {
// Check if item already exists to correctly update cache size
const existingItem = await this._getDBItemOnly(hash); // Helper needed to get without updating timestamp
if (existingItem && typeof existingItem.size === 'number') {
this.currentCacheSize -= existingItem.size; // Subtract old size
}
await this._putDBItem(newItem);
this.currentCacheSize += size; // Add new size
console.log(`TTS Factory: Cached speech for hash ${hash}. New size: ${size}. Total cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
// Trigger size check asynchronously
this.manageCacheSize().catch(error => {
console.error("TTS Factory: Error during post-cache size management:", error);
});
} catch (error) {
console.error(`TTS Factory: Error caching speech for hash ${hash}:`, error);
// Attempt to revert cache size change if put failed?
// Might be complex, log and potentially mark cache as unhealthy
}
}
/**
* Generate a hash for a speech request
* @param {string} text - Text to generate hash for
* @returns {Promise<string>} - Hash value
* Manages the cache size, ensuring it doesn't exceed the limit using LRU.
* @returns {Promise<void>}
*/
async generateSpeechHash(text) {
// For now, just use the text as the hash
// In a more complex implementation, you could include voice ID and other parameters
// You could also use a proper hashing function
return `${this.activeHandler}-${text}`;
async manageCacheSize() {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("TTSFactory: Cache DB not ready for size management.");
return;
}
let iterations = 0;
const maxIterations = 100; // Safety break to prevent infinite loops
try {
// Ensure currentCacheSize is up-to-date before starting eviction
// This is important especially on startup or if background writes happened
this.currentCacheSize = await this._calculateTotalCacheSize();
console.log(`TTS Factory: Recalculated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
while (this.currentCacheSize > this.maxCacheSizeBytes && iterations < maxIterations) {
iterations++;
console.log(`TTS Factory: Cache limit exceeded (${(this.currentCacheSize / (1024*1024)).toFixed(2)}MB > ${(this.maxCacheSizeBytes / (1024*1024)).toFixed(2)}MB). Evicting oldest entry.`);
const sortedItems = await this._getAllDBItemsSortedByAccess();
if (sortedItems.length === 0) {
console.warn("TTS Factory: Cache size exceeds limit, but no items found to evict.");
this.currentCacheSize = 0; // Reset size if store is empty
break; // Exit loop
}
const oldestItem = sortedItems[0];
console.log(`TTS Factory: Evicting item with hash ${oldestItem.hash}, size ${oldestItem.size}, lastAccessed ${new Date(oldestItem.lastAccessed).toISOString()}`);
await this._deleteDBItem(oldestItem.hash);
if (typeof oldestItem.size === 'number') {
this.currentCacheSize -= oldestItem.size;
} else {
// Size was invalid, recalculate total size for safety
console.warn(`TTS Factory: Evicted item ${oldestItem.hash} had invalid size. Recalculating total size.`);
this.currentCacheSize = await this._calculateTotalCacheSize();
}
console.log(`TTS Factory: New estimated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`);
}
if (iterations >= maxIterations) {
console.error("TTS Factory: Max iterations reached during cache eviction. Cache might still be oversized.");
}
} catch (error) {
console.error("TTS Factory: Error during cache size management:", error);
// Consider setting cache status to error or attempting recovery
}
}
/**
* Check if speech is cached by text
* @param {string} text - Text to check
* @returns {boolean} - True if cached
* Checks if speech for the given text is likely cached.
* @param {string} text - The original text.
* @returns {Promise<boolean>}
*/
async isSpeechCached(text) {
if (!this.cacheInitialized && this.cacheStatus !== 'ready') {
console.warn("TTSFactory: Cache not ready for checking.");
return false;
}
const handler = this.getActiveHandler();
if (!handler) return false;
const hash = await this.generateSpeechHash(text);
return this.audioCache && this.audioCache.has(hash);
try {
const item = await this._getDBItem(hash); // _getDBItem updates timestamp if found
return !!item;
} catch (error) {
console.error(`TTS Factory: Error checking cache for hash ${hash}:`, error);
return false;
}
}
/**
* Cache speech data with text as key
* @param {string} text - Text used for the speech
* @param {Object} audioData - The audio data to cache
* Opens and initializes the IndexedDB database.
*/
async cacheSpeech(text, audioData) {
const hash = await this.generateSpeechHash(text);
this.addToCache(hash, audioData);
async _initializeDB() {
return new Promise((resolve, reject) => {
if (this.db) {
resolve(); // Already initialized
return;
}
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = (event) => {
console.error("IndexedDB error:", event.target.error);
this.cacheStatus = 'error';
reject(new Error(`IndexedDB error: ${event.target.error.message}`));
};
request.onsuccess = (event) => {
this.db = event.target.result;
console.log("IndexedDB initialized successfully.");
this.cacheStatus = 'ready';
// Calculate initial size after successful opening
this._calculateTotalCacheSize().then(size => {
this.currentCacheSize = size;
console.log(`Initial cache size: ${(size / (1024*1024)).toFixed(2)} MB`);
resolve();
}).catch(error => {
console.error("Error calculating initial cache size:", error);
this.cacheStatus = 'error';
reject(error); // Propagate calculation error
});
};
request.onupgradeneeded = (event) => {
console.log("IndexedDB upgrade needed.");
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'hash' });
// Index for LRU eviction
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
// Index to potentially help with size calculation, though iterating might be needed anyway
store.createIndex('size', 'size', { unique: false });
console.log(`Object store '${this.storeName}' created.`);
} else {
// Handle potential future schema upgrades here if needed
console.log(`Object store '${this.storeName}' already exists.`);
const transaction = event.target.transaction;
const store = transaction.objectStore(this.storeName);
// Ensure indexes exist if upgrading from a version without them
if (!store.indexNames.contains('lastAccessed')) {
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
console.log("Created 'lastAccessed' index.");
}
if (!store.indexNames.contains('size')) {
store.createIndex('size', 'size', { unique: false });
console.log("Created 'size' index.");
}
}
};
});
}
/**
* Gets an item from the IndexedDB store and updates its lastAccessed timestamp.
* @param {string} hash - The key (hash) of the item to retrieve.
* @returns {Promise<Blob|null>} - The audio data Blob or null if not found.
*/
async _getDBItem(hash) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot get item.");
return null;
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite'); // Need readwrite to update timestamp
const store = transaction.objectStore(this.storeName);
const request = store.get(hash);
request.onerror = (event) => {
console.error("Error getting item from IndexedDB:", event.target.error);
reject(event.target.error);
};
request.onsuccess = (event) => {
const result = event.target.result;
if (result) {
// Update lastAccessed timestamp
result.lastAccessed = Date.now();
const updateRequest = store.put(result);
updateRequest.onerror = (updateEvent) => {
console.error("Error updating lastAccessed timestamp:", updateEvent.target.error);
// Still resolve with data, timestamp update failure is non-critical for retrieval
resolve(result.data);
};
updateRequest.onsuccess = () => {
// console.log(`Updated lastAccessed for hash: ${hash}`);
resolve(result.data);
};
} else {
resolve(null); // Not found
}
};
transaction.oncomplete = () => {
// Transaction completed (either get or get+update)
};
transaction.onerror = (event) => {
console.error("Readwrite transaction error during get/update:", event.target.error);
// If transaction failed before request.onsuccess, we need to reject
if (!request.result) { // Check if we already resolved
reject(event.target.error);
}
};
});
}
/**
* Adds or updates an item in the IndexedDB store.
* @param {object} item - The item object { hash: string, data: Blob, size: number, lastAccessed: number }.
* @returns {Promise<void>}
*/
async _putDBItem(item) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot put item.");
return Promise.reject(new Error("IndexedDB not ready"));
}
if (!item || !item.hash || !item.data || item.size === undefined || item.lastAccessed === undefined) {
console.error("Invalid item provided to _putDBItem:", item);
return Promise.reject(new Error("Invalid item format"));
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(item);
request.onerror = (event) => {
console.error("Error putting item into IndexedDB:", event.target.error);
reject(event.target.error);
};
request.onsuccess = () => {
// console.log(`Successfully put item with hash: ${item.hash}`);
resolve();
};
transaction.onerror = (event) => {
console.error("Readwrite transaction error during put:", event.target.error);
reject(event.target.error);
};
});
}
/**
* Deletes an item from the IndexedDB store.
* @param {string} hash - The key (hash) of the item to delete.
* @returns {Promise<void>}
*/
async _deleteDBItem(hash) {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot delete item.");
return Promise.reject(new Error("IndexedDB not ready"));
}
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(hash);
request.onerror = (event) => {
console.error("Error deleting item from IndexedDB:", event.target.error);
reject(event.target.error);
};
request.onsuccess = () => {
// console.log(`Successfully deleted item with hash: ${hash}`);
resolve();
};
transaction.onerror = (event) => {
console.error("Readwrite transaction error during delete:", event.target.error);
reject(event.target.error);
};
});
}
/**
* Calculates the total size of all items currently in the cache.
* @returns {Promise<number>} - The total size in bytes.
*/
async _calculateTotalCacheSize() {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot calculate size.");
return 0;
}
return new Promise((resolve, reject) => {
let totalSize = 0;
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const cursorRequest = store.openCursor();
cursorRequest.onerror = (event) => {
console.error("Error opening cursor for size calculation:", event.target.error);
reject(event.target.error);
};
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
// Check if size property exists and is a number
if (typeof cursor.value.size === 'number') {
totalSize += cursor.value.size;
} else {
console.warn(`Item with hash ${cursor.key} missing or invalid size property.`);
// Optionally try to get blob size here, but might be slow
}
cursor.continue();
} else {
// No more entries
resolve(totalSize);
}
};
transaction.onerror = (event) => {
console.error("Readonly transaction error during size calculation:", event.target.error);
reject(event.target.error);
};
});
}
/**
* Gets all items sorted by lastAccessed timestamp (ascending, oldest first).
* @returns {Promise<Array<object>>} - Array of cache item objects.
*/
async _getAllDBItemsSortedByAccess() {
if (!this.db || this.cacheStatus !== 'ready') {
console.warn("IndexedDB not ready, cannot get sorted items.");
return [];
}
return new Promise((resolve, reject) => {
const items = [];
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const index = store.index('lastAccessed'); // Use the index
const cursorRequest = index.openCursor(); // Open cursor on the index
cursorRequest.onerror = (event) => {
console.error("Error opening cursor on lastAccessed index:", event.target.error);
reject(event.target.error);
};
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
items.push(cursor.value); // Add the object to the array
cursor.continue();
} else {
// No more entries
resolve(items);
}
};
transaction.onerror = (event) => {
console.error("Readonly transaction error during sorted get:", event.target.error);
reject(event.target.error);
};
});
}
/**
* Helper to get item data without updating the lastAccessed timestamp.
* Used internally by cacheSpeech to check existing size.
* @param {string} hash
* @returns {Promise<object|null>}
*/
async _getDBItemOnly(hash) {
if (!this.db || this.cacheStatus !== 'ready') return null;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.get(hash);
request.onerror = (event) => reject(event.target.error);
request.onsuccess = (event) => resolve(event.target.result || null);
});
}
/**
* Generates a SHA-256 hash for the given string.
* @param {string} text - Input text.
* @returns {Promise<string>} - Hexadecimal hash string.
*/
async _generateHash(text) {
try {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// Convert to hex string
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch (error) {
console.error("Error generating SHA-256 hash:", error);
// Fallback to simple text if crypto fails (less ideal for caching complex text)
return text.replace(/[^a-zA-Z0-9]/g, ''); // Basic fallback
}
}
/**
* Clean up when module is disposed
*/