/** * Kokoro TTS Handler * Provides neural TTS via Kokoro.js */ import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class KokoroHandler extends TTSHandler { constructor() { super(); this.id = 'kokoro'; this.name = 'Kokoro TTS Handler'; // Kokoro instance this.kokoro = null; // Available voices this.voices = [ { id: 'de_DE-neural', name: 'German (Neural)', lang: 'de-DE' }, { id: 'en_US-neural', name: 'English (Neural)', lang: 'en-US' } ]; // Current voice this.currentVoice = null; // Voice options this.options = { volume: 1.0, rate: 1.0, pitch: 1.0 }; // State this.available = false; this.loading = false; this.currentAudio = null; this.preloadCache = new Map(); this.worker = null; this.pendingGeneration = null; // Dependencies this.dependencies = ['localization', 'persistence-manager']; // Bind methods this.bindMethods([ 'initialize', 'speak', 'speakPreloaded', 'preloadSpeech', 'stop', 'pause', 'resume', 'getVoices', 'setVoice', 'setOptions', 'setupVoiceFromPreferences', 'getId', 'getModule' ]); } /** * Initialize the Kokoro TTS handler * @param {Function} progressCallback - Callback for progress updates * @returns {Promise} - Resolves with success status */ async initialize(progressCallback = null) { if (this.loading) { return new Promise((resolve) => { // Wait for loading to complete this.addEventListener(document, 'kokoro-loading-complete', (event) => { resolve(event.detail?.success || false); }, { once: true }); }); } if (this.available) { return true; } this.loading = true; try { // Report progress if (progressCallback) { progressCallback(10, "Loading Kokoro TTS"); } // Initialize web worker try { if (progressCallback) { progressCallback(20, "Initializing Kokoro worker"); } // Create worker this.worker = new Worker('/js/kokoro-worker.js'); // Set up message handler this.worker.onmessage = (e) => { const message = e.data; switch (message.type) { case 'ready': console.log('Kokoro worker is ready'); break; case 'generated': // Handle generated speech if (this.pendingGeneration) { const { text, resolve, reject } = this.pendingGeneration; this.pendingGeneration = null; try { // Create audio from the returned buffer const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = audioContext.createBuffer( 1, // mono message.result.audio.length, message.result.sampling_rate ); // Copy the audio data to the buffer const channelData = audioBuffer.getChannelData(0); channelData.set(new Float32Array(message.result.audio)); // Create audio element const audio = new Audio(); const source = audioContext.createBufferSource(); source.buffer = audioBuffer; // Connect to destination source.connect(audioContext.destination); // Create a play function const play = () => { source.start(0); }; resolve({ audio, play, buffer: audioBuffer }); } catch (error) { console.error('Error processing Kokoro audio:', error); reject(error); } } break; case 'error': console.error('Kokoro worker error:', message.error); if (this.pendingGeneration) { const { reject } = this.pendingGeneration; this.pendingGeneration = null; reject(new Error(message.error)); } break; case 'progress': if (progressCallback) { const progress = 20 + Math.round(message.progress * 80); progressCallback(progress, `Loading Kokoro model: ${Math.round(message.progress * 100)}%`); } break; } }; // Set up error handler this.worker.onerror = (error) => { console.error('Kokoro worker error:', error); if (this.pendingGeneration) { const { reject } = this.pendingGeneration; this.pendingGeneration = null; reject(error); } }; // Initialize the worker this.worker.postMessage({ type: 'init' }); // Wait for worker to be ready await new Promise((resolve, reject) => { // Set up message handler for initialization const messageHandler = (e) => { const message = e.data; if (message.type === 'ready') { this.worker.removeEventListener('message', messageHandler); resolve(); } else if (message.type === 'error') { this.worker.removeEventListener('message', messageHandler); reject(new Error(message.error)); } }; this.worker.addEventListener('message', messageHandler); // Set timeout for initialization setTimeout(() => { this.worker.removeEventListener('message', messageHandler); reject(new Error('Timeout initializing Kokoro worker')); }, 30000); }); console.log('Kokoro worker initialized successfully'); // Mark as available this.available = true; // Set up voice based on preferences and locale await this.setupVoiceFromPreferences(); // Report progress if (progressCallback) { progressCallback(100, "Kokoro TTS ready"); } // Dispatch event this.dispatchEvent('kokoro-loading-complete', { success: true }); this.loading = false; return true; } catch (error) { console.error('Error initializing Kokoro worker:', error); // Try to fall back to direct method try { if (progressCallback) { progressCallback(20, "Initializing Kokoro directly"); } // Load Kokoro script await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '/js/kokoro.js'; script.onload = () => { if (window.kokoro) { this.kokoro = window.kokoro; resolve(); } else { reject(new Error('Kokoro not found after script load')); } }; script.onerror = (e) => { reject(new Error('Error loading Kokoro script')); }; document.head.appendChild(script); // Set timeout setTimeout(() => { reject(new Error('Timeout loading Kokoro script')); }, 10000); }); // Function to report progress const progress = (progress) => { if (progressCallback) { const scaledProgress = 20 + Math.round(progress * 80); progressCallback(scaledProgress, `Loading Kokoro model: ${Math.round(progress * 100)}%`); } }; // Initialize Kokoro await this.kokoro.init({ progress }); console.log('Kokoro initialized successfully'); // Mark as available this.available = true; // Set up voice based on preferences and locale await this.setupVoiceFromPreferences(); // Report progress if (progressCallback) { progressCallback(100, "Kokoro TTS ready"); } // Dispatch event this.dispatchEvent('kokoro-loading-complete', { success: true }); this.loading = false; return true; } catch (directError) { console.error('Error initializing Kokoro directly:', directError); // Report progress if (progressCallback) { progressCallback(100, "Kokoro TTS failed to initialize"); } // Dispatch event this.dispatchEvent('kokoro-loading-complete', { success: false, error: directError }); this.loading = false; return false; } } } catch (error) { console.error('Error initializing Kokoro:', error); // Report progress if (progressCallback) { progressCallback(100, "Kokoro TTS failed to initialize"); } // Dispatch event this.dispatchEvent('kokoro-loading-complete', { success: false, error }); this.loading = false; return false; } } /** * Get a module from the registry * @param {string} moduleId - ID of the module to get * @returns {Object|null} - The module or null if not found */ getModule(moduleId) { return moduleRegistry.getModule(moduleId); } /** * Set up voice based on preferences and locale * @returns {Promise} - Resolves with success status */ async setupVoiceFromPreferences() { try { // Get localization and persistence manager modules const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); // Get current locale and preferred voice let currentLocale = 'en-us'; let preferredVoice = ''; if (localization) { currentLocale = localization.getLocale(); } else { console.warn("Kokoro TTS: Localization module not found"); } if (persistenceManager) { preferredVoice = persistenceManager.getPreference('tts', 'voice', ''); } else { console.warn("Kokoro TTS: Persistence Manager module not found"); } // If we have a preferred voice, use it if (preferredVoice) { const success = this.setVoice(preferredVoice); if (success) return true; } // Otherwise select based on locale return this.selectVoiceForLocale(currentLocale); } catch (error) { console.error("Error setting up voice from preferences:", error); return this.selectDefaultVoice(); } } /** * Select a voice for the given locale * @param {string} locale - Locale code * @returns {boolean} - Success status */ selectVoiceForLocale(locale) { if (!locale || this.voices.length === 0) { return this.selectDefaultVoice(); } // Normalize locale const normalizedLocale = locale.toLowerCase(); // Try to find a voice for the exact locale let matchingVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase() === normalizedLocale ); // If no exact match, try to find a voice for the language part if (!matchingVoice) { const langPart = normalizedLocale.split('-')[0]; matchingVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith(langPart) ); } // If still no match, use default if (!matchingVoice) { return this.selectDefaultVoice(); } // Set the matching voice this.currentVoice = matchingVoice; console.log(`Kokoro TTS: Selected voice ${matchingVoice.name} for locale ${locale}`); // Update preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'voice', matchingVoice.id); } return true; } /** * Select a default voice * @returns {boolean} - Success status */ selectDefaultVoice() { if (this.voices.length === 0) { console.warn("Kokoro TTS: No voices available for default selection"); return false; } // Prefer English voices if available const englishVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith('en') ); if (englishVoice) { this.currentVoice = englishVoice; console.log(`Kokoro TTS: Selected default English voice ${englishVoice.name}`); } else { // Otherwise use the first available voice this.currentVoice = this.voices[0]; console.log(`Kokoro TTS: Selected first available voice ${this.voices[0].name}`); } // Update preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'voice', this.currentVoice.id); } return true; } /** * Preload speech for a text * @param {string} text - Text to preload * @returns {Promise} - Preloaded audio data */ async preloadSpeech(text) { if (!this.available || !text) { return null; } try { // Preprocess text const processedText = this.preprocessText(text); // Check if already cached const cacheKey = `${this.currentVoice?.id || 'default'}_${processedText}`; if (this.preloadCache.has(cacheKey)) { return this.preloadCache.get(cacheKey); } // Generate speech const result = await this.generateSpeech(processedText); // Cache result this.preloadCache.set(cacheKey, result); return result; } catch (error) { console.error("Kokoro: 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 || !preloadData) { if (callback) { setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); } return false; } try { // Stop any current speech this.stop(); // Play the audio if (preloadData.play) { // Use the play function if available preloadData.play(); // Call callback after audio duration if (callback) { setTimeout(() => { callback({ success: true }); }, preloadData.buffer.duration * 1000); } } else if (preloadData.audio) { // Set up event handlers preloadData.audio.onended = () => { if (callback) { callback({ success: true }); } }; preloadData.audio.onerror = (error) => { console.error("Kokoro: Audio playback error:", error); if (callback) { callback({ success: false, reason: 'playback_error', error }); } }; // Store reference to current audio this.currentAudio = preloadData.audio; // Play the audio preloadData.audio.play(); } else { console.error("Kokoro: Invalid preload data"); if (callback) { setTimeout(() => callback({ success: false, reason: 'invalid_data' }), 0); } return false; } return true; } catch (error) { console.error("Kokoro: Error playing preloaded speech:", error); if (callback) { setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0); } 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 || !text) return false; try { // Stop any current speech this.stop(); // Preprocess text const processedText = this.preprocessText(text); // Generate speech const result = await this.generateSpeech(processedText); // Create audio element const audio = new Audio(); // Set up event handlers return new Promise((resolve) => { audio.onended = () => { this.currentAudio = null; resolve(true); }; audio.onerror = (error) => { console.error("Kokoro TTS: Audio playback error:", error); this.currentAudio = null; resolve(false); }; // Store reference to current audio this.currentAudio = audio; // Play the audio if (result.play) { // Use the play function if available result.play(); // Resolve after duration setTimeout(() => { this.currentAudio = null; resolve(true); }, result.buffer.duration * 1000); } else if (result.audio) { // Play the audio element result.audio.play().catch(error => { console.error("Kokoro TTS: Failed to play audio:", error); this.currentAudio = null; resolve(false); }); } else { console.error("Kokoro TTS: Invalid result data"); this.currentAudio = null; resolve(false); } }); } catch (error) { console.error("Error speaking text with Kokoro TTS:", error); return false; } } /** * Generate speech using the web worker or direct method * @param {string} text - Text to generate speech for * @returns {Promise} - Resolves with audio data */ async generateSpeech(text) { if (this.worker) { return new Promise((resolve, reject) => { this.pendingGeneration = { text, resolve, reject }; // Send message to worker to generate speech this.worker.postMessage({ type: 'generate', text, voice: this.currentVoice?.id || 'en_US-neural', speed: this.options.rate }); }); } else if (this.kokoro) { // Fallback to direct method if worker is not available return this.kokoro(text, { voice: this.currentVoice?.id || 'en_US-neural', speed: this.options.rate, autoPlay: false }); } else { throw new Error('No Kokoro implementation available'); } } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Processed text */ preprocessText(text) { if (!text) return ''; // Trim whitespace let processed = text.trim(); // Replace multiple spaces with a single space processed = processed.replace(/\s+/g, ' '); // Add a period at the end if there's no punctuation if (!/[.!?]$/.test(processed)) { processed += '.'; } return processed; } /** * Stop speaking * @returns {boolean} - Success status */ stop() { if (!this.currentAudio) return true; try { this.currentAudio.pause(); this.currentAudio.currentTime = 0; this.currentAudio = null; return true; } catch (error) { console.error("Kokoro: Error stopping speech:", error); return false; } } /** * Pause speaking * @returns {boolean} - Success status */ pause() { if (!this.currentAudio) return false; try { this.currentAudio.pause(); return true; } catch (error) { console.error("Kokoro: Error pausing speech:", error); return false; } } /** * Resume speaking * @returns {boolean} - Success status */ resume() { if (!this.currentAudio) return false; try { this.currentAudio.play(); return true; } catch (error) { console.error("Kokoro: Error resuming speech:", error); return false; } } /** * Check if TTS is available * @returns {boolean} - True if TTS is available */ isAvailable() { return this.available; } /** * Get handler ID * @returns {string} - Handler ID */ getId() { return this.id; } /** * Get available voices * @returns {Array} - Array of voice objects */ getVoices() { return this.voices.map(voice => ({ id: voice.id, name: voice.name, lang: voice.lang })); } /** * Set the voice by ID or name * @param {string} voiceId - Voice ID or name * @returns {boolean} - Success status */ setVoice(voiceId) { if (!voiceId) return false; const voice = this.voices.find(v => v.id === voiceId || v.name === voiceId); if (!voice) { console.warn(`Kokoro TTS: Voice '${voiceId}' not found`); return false; } this.currentVoice = voice; console.log(`Kokoro TTS: Set voice to ${voice.name}`); // Update preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'voice', voice.id); } return true; } /** * Set voice options * @param {Object} options - Voice options * @returns {boolean} - Success status */ setOptions(options = {}) { if (!options) return false; // Update options if (typeof options.volume === 'number') this.options.volume = options.volume; if (typeof options.rate === 'number') { // Clamp rate between 0.5 and 2.0 this.options.rate = Math.max(0.5, Math.min(2.0, options.rate)); } if (typeof options.pitch === 'number') this.options.pitch = options.pitch; // Update preferences const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { if (typeof options.volume === 'number') { persistenceManager.updatePreference('tts', 'volume', options.volume); } if (typeof options.rate === 'number') { persistenceManager.updatePreference('tts', 'rate', options.rate); } } return true; } }