Files
ai.interactive.fiction/public/js/tts-factory.js
T

754 lines
28 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
// LRU Cache for preloaded speech
this.audioCache = new Map();
this.maxCacheSize = 20; // Maximum number of cached items
this.cacheHits = 0;
this.cacheMisses = 0;
// 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',
'addToCache',
'manageCacheSize',
'cacheSpeech',
'isSpeechCached'
]);
}
/**
* 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;
}
// Register all available handlers (this will overwrite any existing handlers)
console.log('TTS Factory: Registering all handlers');
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");
// 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 };
}));
}
// Wait for all handlers to initialize
const results = await Promise.all(initPromises);
console.log('TTS Factory: All handler initialization results:', results);
// Get user preferences
const ttsEnabled = this.getPreference('tts', 'enabled', false);
let preferredProvider = this.getPreference('tts', 'provider', '');
// 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;
if (ttsEnabled) {
// Try to initialize preferred handler first
this.reportProgress(50, `Initializing preferred TTS handler: ${preferredProvider}`);
initSuccess = this.initStatus[preferredProvider] || false;
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 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');
}
}
}
// 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;
}
try {
return await this.handlers[this.activeHandler].speak(text, options);
} catch (error) {
console.error("Error speaking text:", 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 = 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) {
this.addToCache(hash, preloadData);
console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.audioCache.size}/${this.maxCacheSize})`);
}
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 {Object|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);
}
/**
* Add speech data to the cache
* @param {string} hash - Hash of the speech data
* @param {Object} data - Speech data to cache
*/
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);
}
}
/**
* Generate a hash for a speech request
* @param {string} text - Text to generate hash for
* @returns {Promise<string>} - Hash value
*/
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}`;
}
/**
* Check if speech is cached by text
* @param {string} text - Text to check
* @returns {boolean} - True if cached
*/
async isSpeechCached(text) {
const hash = await this.generateSpeechHash(text);
return this.audioCache && this.audioCache.has(hash);
}
/**
* Cache speech data with text as key
* @param {string} text - Text used for the speech
* @param {Object} audioData - The audio data to cache
*/
async cacheSpeech(text, audioData) {
const hash = await this.generateSpeechHash(text);
this.addToCache(hash, audioData);
}
/**
* 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 };