/** * KokoroTTSModule for AI Interactive Fiction * Implementation using the Kokoro library */ import { TTSHandlerModule } from './tts-handler-module.js'; export class KokoroTTSModule extends TTSHandlerModule { constructor() { super('kokoro-tts', 'Kokoro TTS'); // Declare proper dependencies according to architecture principles this.dependencies = ['persistence-manager', 'localization', 'game-config']; // State this.iframe = null; this.currentAudio = null; this.pendingGenerations = new Map(); this.generationCounter = 0; this.voices = []; this.lastProgressTime = null; this.lastProgressValue = null; this.modelLoaded = false; this.unsupportedReason = ''; // Options for playback this.options = { volume: 1.0, rate: 1.0 }; // Bind additional methods beyond those in TTSHandlerModule this.bindMethods([ 'handleIframeMessage', 'setupVoiceFromPreferences', 'generateSpeech', 'speakPreloaded', 'preprocessText', 'pause', 'resume', 'getDefaultVoices', 'setVoiceOptions', 'supportsGameLanguage' ]); } /** * Initialize the Kokoro TTS module * @returns {Promise} - Resolves with success status */ async initialize() { try { console.log('Kokoro TTS: Initializing'); // Get dependencies this.reportProgress(10, 'Loading dependencies'); // The persistence manager is required for preferences const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { console.error('Kokoro TTS: Required dependency persistence-manager not found'); return false; } const gameConfig = this.getModule('game-config'); const gameLanguage = gameConfig?.getLocale?.() || 'en_US'; if (!this.supportsGameLanguage(gameLanguage)) { this.voices = []; this.isReady = false; this.unsupportedReason = `Kokoro TTS supports English and Chinese only; game language is ${gameLanguage}`; this.reportProgress(100, 'Kokoro TTS disabled for this language'); console.log(`Kokoro TTS: ${this.unsupportedReason}`); return true; } this.unsupportedReason = ''; this.addEventListener(document, 'preference-updated', (event) => { const { category, key } = event.detail || {}; if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) { this.currentAudio.volume = this.getPlaybackVolume(); } }); const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false); const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none'); if (!ttsEnabled || preferredHandler !== this.id) { this.voices = this.getDefaultVoices(); this.isReady = false; this.reportProgress(100, 'Kokoro TTS not selected'); console.log('Kokoro TTS: Skipping model load because provider is not selected'); return true; } // Try to check if the kokoro-js.js resource exists before proceeding try { this.reportProgress(20, 'Checking for Kokoro TTS resources'); const response = await fetch('/js/kokoro-js.js', { method: 'HEAD' }); if (!response.ok) { console.error(`Kokoro TTS: Required resource kokoro-js.js not found (${response.status})`); throw new Error('Kokoro TTS resource not available'); } console.log('Kokoro TTS: Resources available'); } catch (resourceError) { console.error('Kokoro TTS: Error checking resources', resourceError); return false; } // Create iframe for Kokoro TTS this.reportProgress(30, 'Creating Kokoro TTS iframe'); console.log('Kokoro TTS: Creating iframe for Kokoro loader'); const iframe = document.createElement('iframe'); iframe.src = '/kokoro-loader.html'; iframe.style.display = 'none'; document.body.appendChild(iframe); this.iframe = iframe; // Wait for iframe to load try { await new Promise((resolve, reject) => { iframe.onload = () => { console.log('Kokoro TTS: Iframe loaded successfully'); resolve(); }; iframe.onerror = (error) => { console.error('Kokoro TTS: Iframe failed to load:', error); reject(new Error('Kokoro TTS: Iframe failed to load')); }; iframe.onabort = () => { console.error('Kokoro TTS: Iframe load aborted'); reject(new Error('Kokoro TTS: Iframe load aborted')); }; }); } catch (iframeError) { console.error('Kokoro TTS: Error loading iframe:', iframeError); return false; } // Add message event listener for progress updates from iframe window.addEventListener('message', this.handleIframeMessage); // Wait for model to initialize try { this.reportProgress(50, 'Loading Kokoro model'); console.log('Kokoro TTS: Waiting for model to initialize'); await new Promise((resolve, reject) => { // Create one-time handler for kokoro:ready message const readyHandler = (event) => { if (event.data && event.data.type === 'kokoro:ready') { window.removeEventListener('message', readyHandler); // Validate the success status from the event if (event.data.success === false) { console.error('Kokoro TTS: Model initialization failed:', event.data.error || 'Unknown error'); reject(new Error('Kokoro TTS: ' + (event.data.error || 'Model initialization failed'))); return; } console.log('Kokoro TTS: Model initialized successfully'); this.modelLoaded = true; this.voices = event.data.voices || this.getDefaultVoices(); resolve(); } }; window.addEventListener('message', readyHandler); // Send initialization message to iframe this.reportProgress(60, 'Initializing Kokoro model'); console.log('Kokoro TTS: Sending initialization message to iframe'); iframe.contentWindow.postMessage({ type: 'kokoro:initialize' }, '*'); }); } catch (modelError) { console.error('Kokoro TTS: Error initializing model:', modelError); return false; } // Get default voices this.reportProgress(80, 'Loading Kokoro voices'); this.voices = this.getDefaultVoices(); console.log('Kokoro TTS: Loaded default voices:', this.voices); // Set voice based on preferences this.reportProgress(90, 'Setting up voice preferences'); await this.setupVoiceFromPreferences(persistenceManager); console.log('Kokoro TTS: Voice preferences set up'); this.isReady = true; this.reportProgress(100, 'Kokoro TTS initialized'); console.log('Kokoro TTS: Initialization complete'); return true; } catch (error) { console.error('Kokoro TTS: Initialization error:', error); this.isReady = false; return false; } } /** * Handle messages from the iframe * @param {MessageEvent} event - Message event */ handleIframeMessage = (event) => { // Only process messages from our iframe if (!this.iframe || event.source !== this.iframe.contentWindow) { return; } // Process message if (event.data && event.data.type) { switch (event.data.type) { case 'kokoro:progress': if (event.data.progress) { // Track the last time we received a progress update this.lastProgressTime = Date.now(); this.lastProgressValue = event.data.progress; this.modelLoadingProgress = event.data.progress; // Update progress this.reportProgress(60 + Math.floor(event.data.progress * 0.3), `Loading Kokoro model: ${event.data.progress.toFixed(0)}%`); } break; case 'kokoro:ready': // Clear any timeout we might have set this.modelLoaded = true; this.reportProgress(90, 'Kokoro model loaded'); console.log('Kokoro TTS: Model ready event received'); break; case 'kokoro:error': console.error('Kokoro TTS: Error from iframe:', event.data.error); // this.changeState('ERROR'); break; case 'kokoro-generated': // Handle speech generation completion if (event.data.id !== undefined && this.pendingGenerations.has(event.data.id)) { const resolver = this.pendingGenerations.get(event.data.id); this.pendingGenerations.delete(event.data.id); if (!event.data.success || event.data.error) { resolver.reject(new Error(event.data.error || 'Speech generation failed')); } else { const audioData = event.data.result && event.data.result.buffer; console.log('Kokoro: Generation complete, audioData:', audioData ? `${audioData.byteLength} bytes` : 'UNDEFINED'); resolver.resolve({ success: true, audioData: audioData, duration: event.data.duration || 0 }); } } break; case 'kokoro:voices': // Update available voices if (Array.isArray(event.data.voices)) { this.voices = event.data.voices; document.dispatchEvent(new CustomEvent('tts:voices-updated', { detail: { engine: 'kokoro', voices: this.voices } })); } break; } } } /** * Set up the voice from preferences */ async setupVoiceFromPreferences(persistenceManager) { if (!persistenceManager) { return false; } // Get current locale const gameConfig = this.getModule('game-config'); const locale = gameConfig?.getLocale?.() || 'en_US'; // Get preferred voice from preferences const preferredVoiceId = persistenceManager.getPreference('tts', 'kokoro_voice', ''); // Find matching voice let selectedVoice = null; if (preferredVoiceId) { // Try to find the specific voice selectedVoice = this.voices.find(v => v.id === preferredVoiceId); } if (!selectedVoice) { // Find a voice for the current locale const normalizedLocale = locale ? locale.toLowerCase().replace('_', '-') : 'en-us'; const languageCode = normalizedLocale.split('-')[0]; // Try to find an exact locale match selectedVoice = this.voices.find(v => v.lang && v.lang.toLowerCase() === normalizedLocale ); // If not found, try to find a language match if (!selectedVoice) { selectedVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().startsWith(languageCode) ); } // If still not found, use the first voice if (!selectedVoice && this.voices.length > 0) { selectedVoice = this.voices[0]; } } // Set the voice if (selectedVoice) { this.setVoice(selectedVoice); return true; } return false; } /** * Set voice for TTS * @param {Object} voice - Voice to set * @returns {boolean} - Success status */ setVoice(voice) { if (!voice || !voice.id) { return false; } this.currentVoice = voice; // Save to preferences const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'kokoro_voice', voice.id); } // Send message to iframe if (this.iframe && this.iframe.contentWindow) { this.iframe.contentWindow.postMessage({ type: 'kokoro:set-voice', voiceId: voice.id }, '*'); } return true; } /** * Set options for TTS * @param {Object} options - Options to set * @returns {boolean} - Success status */ setOptions(options) { if (!options) { return false; } // Update rate and volume if provided if (options.rate !== undefined) { this.options.rate = options.rate; } if (options.volume !== undefined) { this.options.volume = options.volume; } return true; } setVoiceOptions(options = {}) { if (options.voice) { const voice = this.voices.find(v => v.id === options.voice) || { id: options.voice }; this.setVoice(voice); } if (typeof options.speed === 'number') { this.setOptions({ rate: Math.max(0.5, Math.min(2.0, options.speed)) }); } if (typeof options.volume === 'number') { this.setOptions({ volume: Math.max(0, Math.min(1, options.volume)) }); } } getPlaybackVolume() { const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { return this.options.volume; } const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0); const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0); const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false; const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false; return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0))); } supportsGameLanguage(language) { const normalized = String(language || '').trim().replace('_', '-').toLowerCase(); const languageCode = normalized.split('-')[0]; return languageCode === 'en' || languageCode === 'english' || languageCode === 'zh' || languageCode === 'chinese' || languageCode === 'cmn' || languageCode === 'yue'; } /** * Get available voices * @returns {Array} - Array of voice objects */ async getVoices() { if (this.unsupportedReason) { return []; } // If no voices are loaded yet, return default voices if (!this.voices || this.voices.length === 0) { return this.getDefaultVoices(); } return this.voices; } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Preprocessed text */ preprocessText(text) { // Remove HTML tags text = text.replace(/<[^>]*>/g, ' '); // Replace special characters text = text.replace(/&/g, ' and '); // Normalize whitespace text = text.replace(/\s+/g, ' ').trim(); return text; } /** * Preload speech for later playback * @param {string} text - Text to preload * @returns {Promise} - Resolves with preloaded audio data */ async preloadSpeech(text) { if (!this.isReady) { return { success: false, reason: 'not_ready' }; } // Generate speech audio data const result = await this.generateSpeech(text); if (!result.success) { return { success: false, reason: 'generation_failed' }; } return { success: true, audioData: result.audioData, text, duration: result.duration || 0 }; } /** * 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) { const completionCallback = typeof callback === 'function' ? callback : null; if (!this.isReady || !preloadData || !preloadData.audioData) { if (completionCallback) { completionCallback({ success: false, reason: 'invalid_data' }); } return false; } // Stop any ongoing speech this.stop(); // Create audio from blob const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); audio.volume = this.getPlaybackVolume(); audio.playbackRate = this.options.rate; // Set up event handlers audio.onended = () => { this.isSpeaking = false; if (completionCallback) { completionCallback({ success: true }); } URL.revokeObjectURL(audioUrl); }; audio.onerror = (error) => { this.isSpeaking = false; if (completionCallback) { completionCallback({ success: false, reason: 'playback_error', error }); } URL.revokeObjectURL(audioUrl); }; // Start playback this.currentAudio = audio; this.isSpeaking = true; audio.play().then(() => { document.dispatchEvent(new CustomEvent('tts:audio-started', { detail: { provider: this.id || this.name } })); }).catch(error => { this.isSpeaking = false; if (completionCallback) { completionCallback({ success: false, reason: 'playback_error', error }); } URL.revokeObjectURL(audioUrl); }); return true; } /** * Speak text * @param {string} text - Text to speak * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speak(text, callback = null) { if (!this.isReady) { if (callback) { callback({ success: false, reason: 'not_ready' }); } return false; } // Preprocess text const processedText = this.preprocessText(text); // Generate and play speech this.generateSpeech(processedText).then(result => { if (result.success && result.audioData) { // Create audio blob and URL const audioBlob = new Blob([result.audioData], { type: 'audio/mp3' }); const audioUrl = URL.createObjectURL(audioBlob); // Stop any ongoing speech this.stop(); // Create and play audio const audio = new Audio(audioUrl); audio.volume = this.getPlaybackVolume(); audio.playbackRate = this.options.rate; // Set up event handlers audio.onended = () => { this.isSpeaking = false; if (callback) { callback({ success: true }); } URL.revokeObjectURL(audioUrl); }; audio.onerror = (error) => { this.isSpeaking = false; if (callback) { callback({ success: false, reason: 'playback_error', error }); } URL.revokeObjectURL(audioUrl); }; // Start playback this.currentAudio = audio; this.isSpeaking = true; audio.play().then(() => { document.dispatchEvent(new CustomEvent('tts:audio-started', { detail: { provider: this.id || this.name } })); }).catch(error => { this.isSpeaking = false; if (callback) { callback({ success: false, reason: 'playback_error', error }); } }); } else { if (callback) { callback({ success: false, reason: 'generation_failed' }); } } }).catch(error => { if (callback) { callback({ success: false, reason: 'generation_error', error }); } }); return true; } /** * Generate speech using the iframe * @param {string} text - Text to generate speech for * @returns {Promise} - Resolves with audio data */ async generateSpeech(text) { if (!this.isReady || !this.iframe || !this.iframe.contentWindow) { return { success: false, reason: 'not_ready' }; } // Process text const processedText = this.preprocessText(text); return new Promise((resolve, reject) => { // Generate unique ID for this request const id = this.generationCounter++; // Store resolver functions this.pendingGenerations.set(id, { resolve, reject }); // Send request to iframe this.iframe.contentWindow.postMessage({ type: 'kokoro-generate', text: processedText, id, voice: this.currentVoice ? this.currentVoice.id : null }, '*'); }); } /** * Stop current speech * @returns {boolean} - Success status */ stop() { if (this.currentAudio) { try { this.currentAudio.pause(); this.currentAudio.currentTime = 0; this.currentAudio = null; this.isSpeaking = false; 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 for current locale * @returns {Array} Default voices */ 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' } ]; } } const kokoroTTSModule = new KokoroTTSModule(); export { kokoroTTSModule };