/** * Kokoro TTS Handler * Handles text-to-speech using the Kokoro library */ import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class KokoroHandler extends TTSHandler { /** * Constructor * @param {Object} options - Options for the handler */ constructor(options = {}) { super(options); // Set default options this.options = { rate: 1.0, volume: 1.0, ...options }; // Initialize properties this.id = 'kokoro'; this.name = 'Kokoro TTS Handler'; this.available = false; this.loading = false; this.iframe = null; this.currentAudio = null; this.currentVoice = null; this.preloadCache = new Map(); this.pendingGenerations = new Map(); this.generationCounter = 0; // Default voices (will be replaced by dynamically fetched voices) this.voices = []; // Dependencies this.dependencies = ['localization', 'persistence-manager']; // Bind methods this.initialize = this.initialize.bind(this); this.speak = this.speak.bind(this); this.stop = this.stop.bind(this); this.getVoices = this.getVoices.bind(this); this.setVoice = this.setVoice.bind(this); this.generateSpeech = this.generateSpeech.bind(this); this.preprocessText = this.preprocessText.bind(this); this.speakPreloaded = this.speakPreloaded.bind(this); this.preloadSpeech = this.preloadSpeech.bind(this); this.pause = this.pause.bind(this); this.resume = this.resume.bind(this); this.setOptions = this.setOptions.bind(this); this.setupVoiceFromPreferences = this.setupVoiceFromPreferences.bind(this); this.getId = this.getId.bind(this); this.handleIframeMessage = this.handleIframeMessage.bind(this); } /** * Get the ID of the handler * @returns {string} - Handler ID */ getId() { return 'kokoro'; } /** * Get a module from the registry * @param {string} id - Module ID * @returns {Object} - Module instance */ getModule(id) { return moduleRegistry.getModule(id); } /** * Initialize the handler * @param {Function} progressCallback - Callback for progress updates * @returns {Promise} - Resolves with success status */ async initialize(progressCallback) { try { console.log('Kokoro TTS: Initializing...'); // Check if already initialized if (this.available && this.isReady) { console.log('Kokoro TTS: Already initialized and ready'); return true; } // Set loading flag this.loading = true; this.isReady = false; // Explicitly set to false during initialization // Create iframe if not already created if (!this.iframe) { console.log('Kokoro TTS: Creating iframe'); // Create iframe this.iframe = document.createElement('iframe'); this.iframe.style.display = 'none'; this.iframe.src = '/kokoro-loader.html'; document.body.appendChild(this.iframe); // Add message listener - IMPORTANT: Use an arrow function to preserve 'this' window.addEventListener('message', (event) => this.handleIframeMessage(event)); } // Set up event handler for configuration changes document.addEventListener('tts:configure', (event) => { if (event.detail) { if (typeof event.detail.rate === 'number') { this.options.rate = event.detail.rate; console.log(`Kokoro TTS: Rate updated to ${this.options.rate}`); } if (typeof event.detail.volume === 'number') { this.options.volume = event.detail.volume; console.log(`Kokoro TTS: Volume updated to ${this.options.volume}`); } } }); // Wait for Kokoro to load return new Promise((resolve) => { // Set a timeout to prevent hanging indefinitely const timeout = setTimeout(() => { console.error('Kokoro TTS: Initialization timed out'); this.loading = false; this.isReady = false; this.available = false; resolve(false); }, 30000); // 30 second timeout // Handle progress updates const handleProgress = (progress, message) => { console.log(`Kokoro TTS: Progress ${progress * 100}% - ${message}`); if (progressCallback) { progressCallback(progress, message); } }; // Handle message events const messageHandler = (event) => { if (event.source !== this.iframe.contentWindow) { return; } const data = event.data; if (data.type === 'kokoro-progress') { handleProgress(data.progress, data.message); } else if (data.type === 'kokoro-ready') { console.log('Kokoro TTS: Received ready message from iframe', data); // Remove the message listener window.removeEventListener('message', messageHandler); // Clear the timeout clearTimeout(timeout); // Set availability based on success this.available = data.success; this.loading = false; this.isReady = data.success; // Set isReady flag based on success // Store voices if provided if (data.success && data.voices && Array.isArray(data.voices)) { console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe during initialization`); this.voices = data.voices; } else { console.warn('Kokoro TTS: No voices received during initialization or invalid voices data'); if (data.success) { // If initialization was successful but no voices were received, // use default voices this.voices = this.getDefaultVoices(); console.log('Kokoro TTS: Using default voices as fallback'); } } // Set up voice from preferences if (data.success) { this.setupVoiceFromPreferences().then(() => { console.log('Kokoro TTS: Voice set up from preferences during initialization'); }).catch(error => { console.error('Kokoro TTS: Error setting up voice from preferences during initialization:', error ? (error.message || JSON.stringify(error)) : 'Unknown error'); }); } // Resolve with success status resolve(data.success); } }; // Add the message listener window.addEventListener('message', messageHandler); // Send initialization message to iframe if (this.iframe.contentWindow) { console.log('Kokoro TTS: Sending init message to iframe'); setTimeout(() => { this.iframe.contentWindow.postMessage({ type: 'kokoro-init' }, '*'); }, 500); // Add a small delay to ensure iframe is ready } else { console.error('Kokoro TTS: Cannot access iframe content window'); this.loading = false; this.isReady = false; this.available = false; resolve(false); } }); } catch (error) { console.error('Kokoro TTS: Error initializing:', error ? (error.message || JSON.stringify(error)) : 'Unknown error'); this.loading = false; this.available = false; this.isReady = false; return false; } } /** * Handle messages from the iframe * @param {MessageEvent} event - Message event */ handleIframeMessage(event) { // Ignore messages from other sources if (!this.iframe || event.source !== this.iframe.contentWindow) { return; } const data = event.data; console.log('Kokoro TTS: Received message from iframe:', JSON.stringify(data)); switch (data.type) { case 'kokoro-ready': console.log('Kokoro TTS: Received ready message with success =', data.success); // Store voices if provided if (data.success && data.voices && Array.isArray(data.voices)) { console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe`); this.voices = data.voices; // Set availability and ready flags this.available = true; this.loading = false; this.isReady = true; // Set up voice from preferences after voices are loaded this.setupVoiceFromPreferences().then(() => { console.log('Kokoro TTS: Voice set up from preferences after receiving voices'); // Notify TTS Factory that we're ready now document.dispatchEvent(new CustomEvent('kokoro:ready', { detail: { success: true } })); }).catch(error => { console.error('Kokoro TTS: Error setting up voice from preferences after receiving voices:', error ? (error.message || JSON.stringify(error)) : 'Unknown error'); // Still notify as ready since we have voices, even if preference setup failed document.dispatchEvent(new CustomEvent('kokoro:ready', { detail: { success: true } })); }); // Notify about voices being updated document.dispatchEvent(new CustomEvent('kokoro:voices-updated', { detail: { voices: this.voices } })); } else { console.warn('Kokoro TTS: No voices received from iframe or invalid voices data'); // Even with no voices, mark as ready if success is true if (data.success) { this.voices = this.getDefaultVoices(); this.available = true; this.loading = false; this.isReady = true; console.log('Kokoro TTS: Using default voices as fallback'); // Notify TTS Factory that we're ready document.dispatchEvent(new CustomEvent('kokoro:ready', { detail: { success: true } })); // Notify about voices being available document.dispatchEvent(new CustomEvent('kokoro:voices-updated', { detail: { voices: this.voices } })); } else { this.available = false; this.loading = false; this.isReady = false; console.error('Kokoro TTS: Initialization failed:', data.error || 'Unknown error'); // Notify TTS Factory about failure document.dispatchEvent(new CustomEvent('kokoro:ready', { detail: { success: false, error: data.error || 'Unknown error' } })); } } break; case 'kokoro-generated': // Handle generated speech const pendingGeneration = this.pendingGenerations.get(data.id); if (pendingGeneration) { this.pendingGenerations.delete(data.id); if (data.success) { // Create audio element from the result try { // Create a blob from the buffer const blob = new Blob([data.result.buffer], { type: 'audio/wav' }); // Create audio element const audio = new Audio(URL.createObjectURL(blob)); // Create a play function const play = () => { audio.play().catch(error => { console.error('Error playing Kokoro audio:', error); }); }; pendingGeneration.resolve({ audio, play, blob }); } catch (error) { console.error('Error processing Kokoro audio:', error); pendingGeneration.reject(error); } } else { pendingGeneration.reject(new Error(data.error || 'Unknown error')); } } break; case 'kokoro-log': // Log messages from the iframe if (data.logType === 'error') { console.error(`Kokoro iframe: ${data.message}`); } else { console.log(`Kokoro iframe: ${data.message}`); } break; case 'kokoro-progress': // Progress updates are handled during initialization break; } } /** * Set up the voice from preferences * @returns {Promise} */ async setupVoiceFromPreferences() { try { console.log('Kokoro TTS: Setting up voice from preferences, available voices:', this.voices ? this.voices.length : 0); // If no voices are available yet, use default voice if (!this.voices || this.voices.length === 0) { console.warn('Kokoro TTS: No voices available yet, using default voice'); return; } // Get persistence manager const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.warn('Kokoro TTS: Persistence manager not available'); this.currentVoice = this.voices[0]; // Default to first voice return; } // Get localization const localization = this.getModule('localization'); if (!localization) { console.warn('Kokoro TTS: Localization not available'); this.currentVoice = this.voices[0]; // Default to first voice return; } // Get current locale let currentLocale = 'en-us'; // Default locale if (localization && typeof localization.getLocale === 'function') { currentLocale = localization.getLocale(); console.log('Kokoro TTS: Current locale from localization:', currentLocale); } else { console.warn('Kokoro TTS: getLocale method not available, using default locale'); } // Get voice preference const voiceId = persistenceManager.getPreference('tts-voice-kokoro'); console.log('Kokoro TTS: Preferred voice ID:', voiceId); // Find voice if (voiceId) { const voice = this.voices.find(v => v.id === voiceId); if (voice) { console.log('Kokoro TTS: Found preferred voice:', voice.id, voice.name); this.currentVoice = voice; return; } else { console.warn('Kokoro TTS: Preferred voice not found:', voiceId); } } // Find voice for current locale if (currentLocale) { // Standardize locale format (compare lowercase and handle hyphens/underscores) const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); const localePrefix = normalizedLocale.split('-')[0]; // Get language prefix (en, de, etc.) // First try exact locale match let localeVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === normalizedLocale); // If no exact match, try prefix match (en-US with en-GB for example) if (!localeVoice) { localeVoice = this.voices.find(v => { if (!v.lang) return false; const voiceLocale = v.lang.toLowerCase().replace('_', '-'); return voiceLocale.startsWith(localePrefix + '-'); }); } if (localeVoice) { console.log('Kokoro TTS: Found locale voice:', localeVoice.id, localeVoice.name, 'for locale:', normalizedLocale); this.currentVoice = localeVoice; return; } else { console.warn('Kokoro TTS: No voice found for locale:', normalizedLocale); } } // Default to first voice if available if (this.voices.length > 0) { console.log('Kokoro TTS: Using first available voice:', this.voices[0].id, this.voices[0].name); this.currentVoice = this.voices[0]; } else { console.warn('Kokoro TTS: No voices available after all checks'); } } catch (error) { // Log detailed error information console.error('Kokoro TTS: Error setting up voice from preferences:', error ? error.message || JSON.stringify(error) : 'Unknown error'); // Default to first voice if available if (this.voices && this.voices.length > 0) { console.log('Kokoro TTS: Falling back to first voice after error'); this.currentVoice = this.voices[0]; } else { console.warn('Kokoro TTS: No voices available to fall back to after error'); } } } /** * Set voice for TTS * @param {Object} voice - Voice to set * @returns {boolean} - Success status */ setVoice(voice) { if (!voice || !voice.id) { return false; } // Find voice const foundVoice = this.voices.find(v => v.id === voice.id); if (!foundVoice) { return false; } // Set voice this.currentVoice = foundVoice; // Save preference try { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.setPreference('tts-voice-kokoro', foundVoice.id); } } catch (error) { console.error('Kokoro TTS: Error saving voice preference:', error); } return true; } /** * Set options for TTS * @param {Object} options - Options to set * @returns {boolean} - Success status */ setOptions(options) { if (!options) { return false; } // Update options this.options = { ...this.options, ...options }; return true; } /** * Get available voices * @returns {Array} - Array of available voices */ getVoices() { return this.voices; } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Preprocessed text */ preprocessText(text) { if (!text) { return ''; } // Remove HTML tags let processed = text.replace(/<[^>]*>/g, ''); // Replace special characters processed = processed.replace(/ /g, ' '); processed = processed.replace(/&/g, '&'); processed = processed.replace(/</g, '<'); processed = processed.replace(/>/g, '>'); processed = processed.replace(/"/g, '"'); processed = processed.replace(/'/g, "'"); return processed; } /** * Preload speech for later playback * @param {string} text - Text to preload * @returns {Promise} - Resolves with preloaded audio data */ async preloadSpeech(text) { if (!this.available) { console.warn('Kokoro TTS: Not available'); return null; } try { // Check if already in cache const cacheKey = `${this.currentVoice?.id || 'af_heart'}-${this.options.rate}-${text}`; if (this.preloadCache.has(cacheKey)) { return this.preloadCache.get(cacheKey); } // Generate speech const result = await this.generateSpeech(text); // Store in cache this.preloadCache.set(cacheKey, result); return result; } catch (error) { console.error('Kokoro TTS: Error preloading speech:', error); return null; } } /** * Speak text using preloaded audio * @param {Object} preloadData - Preloaded audio data * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speakPreloaded(preloadData, callback = null) { if (!this.available) { console.warn('Kokoro TTS: Not available'); return false; } try { // Stop any current speech this.stop(); // Create audio element if not already created const audio = preloadData.audio; // Set up event handlers audio.onended = () => { this.currentAudio = null; if (callback) callback(); }; audio.onerror = (error) => { console.error('Kokoro TTS: Audio playback error:', error); this.currentAudio = null; if (callback) callback(error); }; // Set volume audio.volume = this.options.volume; // Store current audio this.currentAudio = audio; // Play audio if (preloadData.play) { preloadData.play(); } else { audio.play().catch(error => { console.error('Kokoro TTS: Error playing audio:', error); this.currentAudio = null; if (callback) callback(error); }); } return true; } catch (error) { console.error('Kokoro TTS: Error speaking preloaded audio:', error); return false; } } /** * Speak text * @param {string} text - Text to speak * @param {Object} options - Speech options * @returns {Promise} - Resolves with success status */ async speak(text, options = {}) { if (!this.available) { console.warn('Kokoro TTS: Not available'); return false; } try { // Stop any current speech this.stop(); console.log('Kokoro TTS: Generating speech for:', text); // Generate speech const result = await this.generateSpeech(text); if (!result || !result.audio) { console.error('Kokoro TTS: Invalid speech generation result'); return false; } // Set up event handlers result.audio.onended = () => { console.log('Kokoro TTS: Audio playback ended'); this.currentAudio = null; // Dispatch event for completion window.dispatchEvent(new CustomEvent('tts:speak-completed')); }; result.audio.onerror = (error) => { console.error('Kokoro TTS: Audio playback error:', error); this.currentAudio = null; // Dispatch event for error window.dispatchEvent(new CustomEvent('tts:speak-error', { detail: { error: error } })); }; // Set volume result.audio.volume = this.options.volume; // Store current audio this.currentAudio = result.audio; console.log('Kokoro TTS: Attempting to play audio'); // Play audio with better error handling try { if (result.play && typeof result.play === 'function') { await result.play(); } else { await result.audio.play(); } console.log('Kokoro TTS: Audio playback started successfully'); return true; } catch (playError) { console.error('Error playing Kokoro audio:', playError); this.currentAudio = null; return false; } } catch (error) { console.error('Kokoro TTS: Error speaking:', error); return false; } } /** * Generate speech using the iframe * @param {string} text - Text to generate speech for * @returns {Promise} - Resolves with audio data */ async generateSpeech(text) { if (!this.iframe || !this.iframe.contentWindow) { throw new Error('Kokoro iframe not initialized'); } // Preprocess text const processedText = this.preprocessText(text); // Ensure we have a valid voice let voiceId = 'af_heart'; // Default fallback if (this.currentVoice && this.currentVoice.id) { voiceId = this.currentVoice.id; } else if (this.voices && this.voices.length > 0) { // If currentVoice is not set but we have voices, use the first one voiceId = this.voices[0].id; this.currentVoice = this.voices[0]; } return new Promise((resolve, reject) => { // Generate unique ID for this request const id = `gen-${++this.generationCounter}`; // Store the pending generation this.pendingGenerations.set(id, { resolve, reject }); // Send the generation request to the iframe this.iframe.contentWindow.postMessage({ type: 'kokoro-generate', id: id, text: processedText, voice: voiceId, speed: this.options.rate }, '*'); }); } /** * Stop current speech * @returns {boolean} - Success status */ stop() { if (this.currentAudio) { try { this.currentAudio.pause(); this.currentAudio.currentTime = 0; this.currentAudio = null; return true; } catch (error) { console.error('Kokoro TTS: Error stopping speech:', error); return false; } } return true; } /** * Pause current speech * @returns {boolean} - Success status */ pause() { if (this.currentAudio) { try { this.currentAudio.pause(); return true; } catch (error) { console.error('Kokoro TTS: Error pausing speech:', error); return false; } } return true; } /** * Resume current speech * @returns {boolean} - Success status */ resume() { if (this.currentAudio) { try { this.currentAudio.play(); return true; } catch (error) { console.error('Kokoro TTS: Error resuming speech:', error); return false; } } return false; } /** * Get default voices * @returns {Array} - Array of default voices * @private */ getDefaultVoices() { return [ // American Female voices { id: 'af_heart', name: 'Heart', lang: 'en-US', gender: 'female' }, { id: 'af_daisy', name: 'Daisy', lang: 'en-US', gender: 'female' }, { id: 'af_soft', name: 'Soft', lang: 'en-US', gender: 'female' }, { id: 'af_glados', name: 'GLaDOS', lang: 'en-US', gender: 'female' }, { id: 'af_southern_belle', name: 'Southern Belle', lang: 'en-US', gender: 'female' }, { id: 'af_dramatic', name: 'Dramatic', lang: 'en-US', gender: 'female' }, { id: 'af_valley_girl', name: 'Valley Girl', lang: 'en-US', gender: 'female' }, { id: 'af_british', name: 'British', lang: 'en-US', gender: 'female' }, { id: 'af_russian', name: 'Russian', lang: 'en-US', gender: 'female' }, { id: 'af_german', name: 'German', lang: 'en-US', gender: 'female' }, { id: 'af_cheeky_cute', name: 'Cheeky Cute', lang: 'en-US', gender: 'female' }, // American Male voices { id: 'am_bruce', name: 'Bruce', lang: 'en-US', gender: 'male' }, { id: 'am_announcer', name: 'Announcer', lang: 'en-US', gender: 'male' }, { id: 'am_radio_host', name: 'Radio Host', lang: 'en-US', gender: 'male' }, // British Female voices { id: 'bf_charlotte', name: 'Charlotte', lang: 'en-GB', gender: 'female' }, { id: 'bf_elizabeth', name: 'Elizabeth', lang: 'en-GB', gender: 'female' }, { id: 'bf_lily', name: 'Lily', lang: 'en-GB', gender: 'female' }, { id: 'bf_olivia', name: 'Olivia', lang: 'en-GB', gender: 'female' }, { id: 'bf_victoria', name: 'Victoria', lang: 'en-GB', gender: 'female' }, // British Male voices { id: 'bm_william', name: 'William', lang: 'en-GB', gender: 'male' }, { id: 'bm_arthur', name: 'Arthur', lang: 'en-GB', gender: 'male' }, { id: 'bm_george', name: 'George', lang: 'en-GB', gender: 'male' }, { id: 'bm_harry', name: 'Harry', lang: 'en-GB', gender: 'male' }, { id: 'bm_jack', name: 'Jack', lang: 'en-GB', gender: 'male' } ]; } }