From fc693ae6957fe83c689943ad3faf79f022c3f951 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 5 Apr 2025 22:06:22 +0000 Subject: [PATCH] Fix Kokoro TTS integration issues: Remove API key requirement and ensure system-specific options display correctly --- public/js/api-tts-module-base.js | 393 ++++++++ public/js/browser-tts-module.js | 570 ++++++++++++ public/js/elevenlabs-tts-module.js | 270 ++++++ public/js/kokoro-handler.js | 2 +- public/js/kokoro-tts-module.js | 657 ++++++++++++++ public/js/loader.js | 14 + public/js/openai-tts-module.js | 255 ++++++ public/js/options-ui.js | 44 +- public/js/tts-factory.js | 1348 ++++++++++++++++++---------- public/js/tts-handler-module.js | 202 +++++ public/kokoro-loader.html | 137 +-- 11 files changed, 3296 insertions(+), 596 deletions(-) create mode 100644 public/js/api-tts-module-base.js create mode 100644 public/js/browser-tts-module.js create mode 100644 public/js/elevenlabs-tts-module.js create mode 100644 public/js/kokoro-tts-module.js create mode 100644 public/js/openai-tts-module.js create mode 100644 public/js/tts-handler-module.js diff --git a/public/js/api-tts-module-base.js b/public/js/api-tts-module-base.js new file mode 100644 index 0000000..1c74fdc --- /dev/null +++ b/public/js/api-tts-module-base.js @@ -0,0 +1,393 @@ +/** + * API TTS Module Base Class + * Base class for API-based TTS modules + */ +import { TTSHandlerModule } from './tts-handler-module.js'; + +export class ApiTTSModuleBase extends TTSHandlerModule { + constructor(id, name) { + super(id, name); + + // Basic voice options + this.voiceOptions = { + speed: 1.0, + voice: null + }; + + // API settings + this.apiKey = ''; + this.apiBaseUrl = ''; + + // State + this.currentAudio = null; + + // Bind additional methods + this.bindMethods([ + 'handleApiKeyChanged', + 'handleApiUrlChanged', + 'speakPreloaded', + 'loadVoices', + 'selectVoiceForLocale', + 'selectDefaultVoice', + 'generateSpeechAudio', + 'preprocessText' + ]); + } + + /** + * Initialize the API TTS module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + this.reportProgress(10, `Initializing ${this.name}`); + + // Initialize parent + const parentInit = await super.initialize(); + if (!parentInit) { + return false; + } + + // Get persistence manager + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.error(`${this.name}: Required dependency 'persistence-manager' not found`); + return false; + } + + // Load API key from preferences + this.apiKey = persistenceManager.getPreference('tts', `${this.id}_api_key`) || ''; + + // Get default API URL + const defaultApiUrl = this.getDefaultApiBaseUrl(); + + // Set up API base URL from preferences or use default + const savedApiUrl = persistenceManager.getPreference('tts', `${this.id}_api_url`); + this.apiBaseUrl = savedApiUrl || defaultApiUrl; + + // If no API URL was saved in preferences, save the default + if (!savedApiUrl && defaultApiUrl) { + persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl); + } + + this.reportProgress(30, `${this.name} API configuration loaded`); + + // Set up event listeners for API key and URL changes + document.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged); + document.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged); + + // Load voices + await this.loadVoices(); + this.reportProgress(50, `${this.name} voices loaded`); + + // Set up voice from preferences + await this.setupVoiceFromPreferences(); + this.reportProgress(70, `${this.name} voice preferences configured`); + + // Check if we have an API key + this.isReady = !!this.apiKey; + + // Always mark as available for UI configuration purposes + // (even if not ready due to missing API key) + this.reportProgress(100, `${this.name} initialization complete`); + + return true; + } + + /** + * Get the default API base URL for this provider + * @returns {string} - Default API base URL + */ + getDefaultApiBaseUrl() { + // To be implemented by subclasses + return ''; + } + + /** + * Set up voice based on preferences and locale + * @returns {Promise} - Resolves with success status + */ + async setupVoiceFromPreferences() { + const persistenceManager = this.getModule('persistence-manager'); + const localization = this.getModule('localization'); + + if (!persistenceManager || !localization) { + return false; + } + + // Get preferred voice ID from preferences + const preferredVoiceId = persistenceManager.getPreference('tts', `${this.id}_voice`, ''); + + // Get current locale + const currentLocale = localization.getLocale(); + + // If we have a preferred voice and available voices, use it + if (preferredVoiceId && this.voices && this.voices.length > 0) { + const voice = this.voices.find(v => v.id === preferredVoiceId); + if (voice) { + this.voiceOptions.voice = voice; + return true; + } + } + + // Otherwise select a voice based on locale + if (currentLocale) { + return this.selectVoiceForLocale(currentLocale); + } + + // Fall back to default voice + return this.selectDefaultVoice(); + } + + /** + * Load available voices from API + * @returns {Promise} - Resolves with success status + */ + async loadVoices() { + // To be implemented by subclasses + this.voices = []; + return true; + } + + /** + * Select a voice for the given locale + * @param {string} locale - Locale code + * @returns {boolean} - Success status + */ + selectVoiceForLocale(locale) { + // To be implemented by subclasses + return this.selectDefaultVoice(); + } + + /** + * Select a default voice + * @returns {boolean} - Success status + */ + selectDefaultVoice() { + if (this.voices && this.voices.length > 0) { + this.voiceOptions.voice = this.voices[0]; + return true; + } + return false; + } + + /** + * Generate speech audio blob for the given text using the API. + * @param {string} text - The text to synthesize. + * @returns {Promise} - A promise that resolves with the audio data object. + */ + async generateSpeechAudio(text) { + // To be implemented by subclasses + return { success: false, reason: 'not_implemented' }; + } + + /** + * Speak preloaded audio data + * @param {Object} preloadData - Preloaded audio data + * @param {Function} callback - Callback for when speech completes + * @returns {boolean} - Success status + */ + speakPreloaded(preloadData, callback = null) { + if (!preloadData || !preloadData.audioData) { + if (callback) { + callback({ success: false, reason: 'invalid_data' }); + } + return false; + } + + // Stop any ongoing speech + this.stop(); + + // Create audio blob + const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' }); + const audioUrl = URL.createObjectURL(audioBlob); + + // Create audio element + const audio = new Audio(audioUrl); + + // 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; + + // Handle play error + audio.play().catch(error => { + this.isSpeaking = false; + if (callback) { + callback({ success: false, reason: 'playback_error', error }); + } + URL.revokeObjectURL(audioUrl); + }); + + return true; + } + + /** + * Stop speaking + * @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(`${this.name}: Error stopping speech:`, error); + return false; + } + } + 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; + } + + // Generate and play speech + this.generateSpeechAudio(text).then(result => { + if (result.success && result.audioData) { + // Create audio from blob and play it + this.speakPreloaded({ audioData: result.audioData }, callback); + } else if (callback) { + callback({ success: false, reason: 'generation_failed' }); + } + }).catch(error => { + if (callback) { + callback({ success: false, reason: 'generation_error', error }); + } + }); + + return true; + } + + /** + * Preload speech for later playback + * @param {string} text - Text to preload + * @returns {Promise} - Preloaded speech data + */ + async preloadSpeech(text) { + if (!this.isReady) { + return { success: false, reason: 'not_ready' }; + } + + try { + // Generate speech + const result = await this.generateSpeechAudio(text); + + if (!result.success) { + return { success: false, reason: 'generation_failed' }; + } + + return { + success: true, + audioData: result.audioData, + text, + duration: result.duration || 0 + }; + } catch (error) { + return { success: false, reason: 'generation_error', error }; + } + } + + /** + * Preprocess text for TTS + * @param {string} text - Text to preprocess + * @returns {string} - Processed text + */ + preprocessText(text) { + if (!text) { + return ''; + } + + // Remove HTML tags + let processed = text.replace(/<[^>]*>/g, ' '); + + // Replace special characters + processed = processed.replace(/&/g, ' and '); + + // Normalize whitespace + processed = processed.replace(/\s+/g, ' ').trim(); + + // Add trailing period if missing + if (!/[.!?]$/.test(processed)) { + processed += '.'; + } + + return processed; + } + + /** + * Handle API key change event + * @param {Event} event - Event object + */ + handleApiKeyChanged(event) { + if (event && event.detail && event.detail.provider === this.id) { + const newKey = event.detail.key || ''; + + // Security check - never use a URL as an API key + if (newKey && newKey.startsWith('http')) { + console.error(`${this.name}: Received URL instead of API key, ignoring it`); + return; + } + + // Update API key + this.apiKey = newKey; + + // Save to preferences + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey); + } + + // Update ready state + this.isReady = !!this.apiKey; + } + } + + /** + * Handle API URL change event + * @param {Event} event - Event object + */ + handleApiUrlChanged(event) { + if (event && event.detail && event.detail.provider === this.id) { + const newUrl = event.detail.url || this.getDefaultApiBaseUrl(); + + // Update API URL + this.apiBaseUrl = newUrl; + + // Save to preferences + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl); + } + } + } +} diff --git a/public/js/browser-tts-module.js b/public/js/browser-tts-module.js new file mode 100644 index 0000000..2af0c36 --- /dev/null +++ b/public/js/browser-tts-module.js @@ -0,0 +1,570 @@ +/** + * BrowserTTSModule for AI Interactive Fiction + * Implementation using the browser's Web Speech API + */ +import { TTSHandlerModule } from './tts-handler-module.js'; + +/** + * Browser TTS Module - Uses the browser's Web Speech API for TTS + */ +export class BrowserTTSModule extends TTSHandlerModule { + constructor() { + super('browser', 'Browser TTS'); + + // Voice options + this.voiceOptions = { + voice: null, // Will be set during initialization + rate: 1.0, + pitch: 1.0, + volume: 1.0 + }; + + // State + this.available = false; + this.currentUtterance = null; + + // Ensure dependencies are correctly defined from parent class + // this.dependencies should already contain ['persistence-manager', 'localization'] + + // Bind additional methods beyond those in TTSHandlerModule + this.bindMethods([ + 'onVoicesChanged', + 'loadVoices', + 'selectVoiceForLocale', + 'synthesizeToWav', + 'speakPreloaded', + 'speak', + 'preprocessText', + 'inferVoiceGender' + ]); + } + + /** + * Initialize the browser TTS module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(10, 'Initializing Browser TTS'); + + // Check for browser support + if (!window.speechSynthesis) { + console.error('Browser TTS: Speech synthesis not available in this browser'); + return false; + } + + this.reportProgress(30, 'Browser TTS supported'); + + // Initialize parent + const parentInit = await super.initialize(); + if (!parentInit) { + console.error('Browser TTS: Parent initialization failed'); + return false; + } + + // Get required dependencies + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.error('Browser TTS: Required dependency persistence-manager not found'); + return false; + } + + const localization = this.getModule('localization'); + if (!localization) { + console.error('Browser TTS: Required dependency localization not found'); + return false; + } + + // Load voices + const voicesLoaded = await this.loadVoices(); + if (!voicesLoaded) { + console.error('Browser TTS: Failed to load voices'); + return false; + } + + // Set speech options from preferences + this.voiceOptions.rate = persistenceManager.getPreference('tts', 'rate', 1.0); + this.voiceOptions.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0); + this.voiceOptions.volume = persistenceManager.getPreference('tts', 'volume', 1.0); + const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); + + // Set voice based on current locale + const currentLocale = localization.getLocale() || 'en-us'; + await this.selectVoiceForLocale(currentLocale, preferredVoice); + + // Listen for locale changes + document.addEventListener('locale:changed', async (event) => { + if (event.detail && event.detail.locale) { + await this.selectVoiceForLocale(event.detail.locale); + } + }); + + // Listen for voices changed events + if (window.speechSynthesis.onvoiceschanged !== undefined) { + window.speechSynthesis.onvoiceschanged = this.onVoicesChanged; + } + + this.isReady = true; + this.available = true; + this.reportProgress(100, 'Browser TTS initialized'); + + return true; + } catch (error) { + console.error('Browser TTS: Initialization error:', error); + this.isReady = false; + this.available = false; + return false; + } + } + + /** + * Handle voices changed event + */ + async onVoicesChanged() { + await this.loadVoices(); + + // Re-select voice based on current locale + const localization = this.getModule('localization'); + const persistenceManager = this.getModule('persistence-manager'); + + if (localization && persistenceManager) { + const currentLocale = localization.getLocale() || 'en-us'; + const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', ''); + await this.selectVoiceForLocale(currentLocale, preferredVoice); + } + } + + /** + * Load available voices from the speech synthesis API + * @returns {Promise} - Resolves with success status + */ + async loadVoices() { + try { + this.reportProgress(40, 'Loading browser voices'); + + // Try to get voices + let voices = window.speechSynthesis.getVoices(); + + // If voices array is empty, wait for onvoiceschanged event + if (!voices || voices.length === 0) { + try { + console.log('Browser TTS: No voices available immediately, waiting for voices to load...'); + + // Wait for voices to be loaded (with timeout) + voices = await new Promise((resolve, reject) => { + // Set a timeout in case voices never load + const timeout = setTimeout(() => { + console.warn('Browser TTS: Timeout waiting for voices'); + // Resolve with empty array instead of rejecting + resolve([]); + }, 3000); + + // Listen for voices changed event + window.speechSynthesis.onvoiceschanged = () => { + clearTimeout(timeout); + const loadedVoices = window.speechSynthesis.getVoices(); + console.log(`Browser TTS: Voices loaded, found ${loadedVoices.length} voices`); + resolve(loadedVoices); + }; + }); + } catch (voiceWaitError) { + console.error('Browser TTS: Error waiting for voices:', voiceWaitError); + // Continue with empty voices array + voices = []; + } + } + + // Store voices + this.voices = voices || []; + + // Log available voices for debugging + console.log(`Browser TTS: Loaded ${this.voices.length} voices`); + if (this.voices.length > 0) { + console.log('Browser TTS: First few voices:', this.voices.slice(0, 3)); + } + + // If no voices available but speech synthesis is supported, still return true + // Some browsers may not expose voices but still support speech synthesis + if (this.voices.length === 0) { + console.warn('Browser TTS: No voices available, but continuing with default voice'); + // Create a default voice entry + this.voices = [{ + default: true, + lang: 'en-US', + localService: true, + name: 'Default Voice', + voiceURI: 'default' + }]; + } + + this.reportProgress(60, 'Browser voices loaded'); + return true; + } catch (error) { + console.error('Browser TTS: Error loading voices:', error); + return false; + } + } + + /** + * Set voice based on locale + * @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr') + * @param {string} preferredVoice - Optional preferred voice name + * @returns {Promise} - Success status + */ + async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') { + // Normalize locale format + locale = locale.toLowerCase().replace('_', '-'); + const languageCode = locale.split('-')[0]; + + // First try to use the preferred voice if specified + if (preferredVoice) { + const voice = this.voices.find(v => + v.name === preferredVoice || + v.voiceURI === preferredVoice + ); + + if (voice) { + this.voiceOptions.voice = voice; + return true; + } + } + + // Try to find a voice that matches the exact locale + const exactMatch = this.voices.find(v => + v.lang.toLowerCase() === locale + ); + + if (exactMatch) { + this.voiceOptions.voice = exactMatch; + return true; + } + + // Try to find a voice that matches the language code + const languageMatch = this.voices.find(v => + v.lang.toLowerCase().startsWith(languageCode) + ); + + if (languageMatch) { + this.voiceOptions.voice = languageMatch; + return true; + } + + // Fallback to the first available voice + if (this.voices.length > 0) { + this.voiceOptions.voice = this.voices[0]; + return true; + } + + // No voices available + return false; + } + + /** + * 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 || !window.speechSynthesis) { + if (callback) { + callback({ success: false, reason: 'not_ready' }); + } + return false; + } + + // Stop any ongoing speech + this.stop(); + + const processedText = this.preprocessText(text); + + // Create utterance + const utterance = new SpeechSynthesisUtterance(processedText); + + // Set options + if (this.voiceOptions.voice) { + utterance.voice = this.voiceOptions.voice; + } + + utterance.rate = this.voiceOptions.rate; + utterance.pitch = this.voiceOptions.pitch; + utterance.volume = this.voiceOptions.volume; + + // Set up event handlers + utterance.onend = () => { + this.isSpeaking = false; + if (callback) { + callback({ success: true }); + } + }; + + utterance.onerror = (error) => { + this.isSpeaking = false; + console.error('Browser TTS: Speech error', error); + if (callback) { + callback({ success: false, reason: 'synthesis_error', error }); + } + }; + + // Store current utterance + this.currentUtterance = utterance; + this.isSpeaking = true; + + // Start speaking + window.speechSynthesis.speak(utterance); + + return true; + } + + /** + * Preload speech for a text + * @param {string} text - Text to preload + * @returns {Promise} - Preloaded speech data + */ + async preloadSpeech(text) { + if (!this.isReady || !window.speechSynthesis) { + return { success: false, reason: 'not_ready' }; + } + + // Generate WAV audio data + const wavResult = await this.synthesizeToWav(text); + + if (!wavResult.success) { + return { success: false, reason: 'synthesis_failed' }; + } + + return { + success: true, + audioData: wavResult.audioData, + text, + duration: wavResult.duration || 0 + }; + } + + /** + * Convert speech synthesis to WAV format + * @param {string} text - Text to synthesize + * @returns {Promise} - Object with audio data + */ + async synthesizeToWav(text) { + return new Promise((resolve) => { + if (!this.isReady || !window.speechSynthesis) { + resolve({ success: false, reason: 'not_ready' }); + return; + } + + // Process text for better synthesis + const processedText = this.preprocessText(text); + + // Create audio context + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) { + resolve({ success: false, reason: 'no_audio_context' }); + return; + } + + const audioContext = new AudioContext(); + + // Create media stream destination + const destination = audioContext.createMediaStreamDestination(); + + // Create media recorder + const mediaRecorder = new MediaRecorder(destination.stream); + const audioChunks = []; + + // Set up event handlers + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunks.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + // Create blob from chunks + const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }); + + // Convert blob to array buffer + const reader = new FileReader(); + reader.onloadend = () => { + resolve({ + success: true, + audioData: reader.result + }); + }; + + reader.onerror = () => { + resolve({ success: false, reason: 'blob_read_error' }); + }; + + reader.readAsArrayBuffer(audioBlob); + }; + + // Create utterance + const utterance = new SpeechSynthesisUtterance(processedText); + + // Set options + if (this.voiceOptions.voice) { + utterance.voice = this.voiceOptions.voice; + } + + utterance.rate = this.voiceOptions.rate; + utterance.pitch = this.voiceOptions.pitch; + utterance.volume = this.voiceOptions.volume; + + // Start recording + mediaRecorder.start(); + + // Set up completion handling + utterance.onend = () => { + mediaRecorder.stop(); + }; + + utterance.onerror = (error) => { + console.error('Browser TTS: Synthesis error', error); + mediaRecorder.stop(); + resolve({ success: false, reason: 'synthesis_error' }); + }; + + // Start speaking + window.speechSynthesis.speak(utterance); + + // Set timeout in case onend never fires + setTimeout(() => { + if (mediaRecorder.state === 'recording') { + mediaRecorder.stop(); + } + }, 30000); // 30-second timeout + }); + } + + /** + * Speak preloaded audio data + * @param {Object} preloadedData - Data from preloadSpeech + * @param {Function} callback - Callback for when speech completes + * @returns {boolean} - Success status + */ + speakPreloaded(preloadedData, callback = null) { + if (!preloadedData || !preloadedData.text) { + console.error('Browser TTS: Invalid preloaded data'); + return false; + } + + // For browser TTS, we don't use the preloaded data directly + // Instead, we just speak the text again + return this.speak(preloadedData.text, callback); + } + + /** + * Preprocess text for TTS + * @param {string} text - Text to preprocess + * @returns {string} - Processed text + */ + preprocessText(text) { + // Remove HTML tags + text = text.replace(/<[^>]*>/g, ' '); + + // Replace special characters with their spoken equivalents + text = text.replace(/&/g, ' and '); + + // Normalize whitespace + text = text.replace(/\s+/g, ' ').trim(); + + return text; + } + + /** + * Stop speaking + * @returns {boolean} - Success status + */ + stop() { + if (window.speechSynthesis) { + window.speechSynthesis.cancel(); + this.isSpeaking = false; + this.currentUtterance = null; + return true; + } + return false; + } + + /** + * Get available voices + * @returns {Array} - Array of voice objects + */ + async getVoices() { + if (!this.isReady) { + return []; + } + + const localization = this.getModule('localization'); + const currentLocale = localization ? localization.getLocale() : 'en-us'; + + // Normalize locale format + const normalizedLocale = currentLocale.toLowerCase().replace('_', '-'); + const languageCode = normalizedLocale.split('-')[0]; + + // Filter voices by current locale + const filteredVoices = this.voices.filter(voice => { + const voiceLang = voice.lang.toLowerCase(); + return voiceLang.startsWith(languageCode) || + voiceLang === normalizedLocale || + (normalizedLocale.startsWith(voiceLang) && voiceLang.length === 2); + }); + + // If matching voices found, use them + if (filteredVoices.length > 0) { + return filteredVoices.map(voice => ({ + id: voice.voiceURI, + name: voice.name, + lang: voice.lang, + gender: this.inferVoiceGender(voice.name) + })); + } + + // If no matching voices found, return all voices + return this.voices.map(voice => ({ + id: voice.voiceURI, + name: voice.name, + lang: voice.lang, + gender: this.inferVoiceGender(voice.name) + })); + } + + /** + * Infer voice gender from name + * @param {string} name - Voice name + * @returns {string} - Inferred gender ('male', 'female', or 'unknown') + */ + inferVoiceGender(name) { + const lowerName = name.toLowerCase(); + + // Common terms indicating gender + const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir']; + const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss']; + + // Check for explicit gender terms in the name + for (const term of maleTerms) { + if (lowerName.includes(term)) return 'male'; + } + + for (const term of femaleTerms) { + if (lowerName.includes(term)) return 'female'; + } + + return 'unknown'; + } +} + +// Register the module with the module registry +// Module registry MUST be accessed via window, not direct import +if (window.moduleRegistry) { + try { + // Create instance first, then register it + const browserTTSModule = new BrowserTTSModule(); + window.moduleRegistry.register(browserTTSModule); + console.log('Browser TTS Module registered successfully'); + } catch (err) { + console.error('Failed to register Browser TTS Module:', err); + } +} else { + console.error('Module registry not available when attempting to register Browser TTS Module'); +} diff --git a/public/js/elevenlabs-tts-module.js b/public/js/elevenlabs-tts-module.js new file mode 100644 index 0000000..58e2dee --- /dev/null +++ b/public/js/elevenlabs-tts-module.js @@ -0,0 +1,270 @@ +/** + * ElevenLabsTTSModule + * Provides TTS via ElevenLabs API + */ +import { ApiTTSModuleBase } from './api-tts-module-base.js'; + +export class ElevenLabsTTSModule extends ApiTTSModuleBase { + constructor() { + super('elevenlabs', 'ElevenLabs TTS'); + + // Voice options specific to ElevenLabs + this.voiceOptions = { + voice: 'pNInz6obpgDQGcFmaJgB', // Default voice ID for ElevenLabs + model: 'eleven_multilingual_v2', // Use the multilingual model + speed: 1.0 + }; + } + + /** + * Initialize the ElevenLabs TTS module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(10, 'Initializing ElevenLabs TTS'); + + // Initialize parent + const parentInit = await super.initialize(); + if (!parentInit) { + console.error('ElevenLabs TTS: Parent initialization failed'); + return false; + } + + // Get required dependencies + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.error('ElevenLabs TTS: Required dependency persistence-manager not found'); + return false; + } + + // Check for API key + const apiKey = persistenceManager.getPreference('elevenlabs', 'api_key', ''); + if (!apiKey) { + console.error('ElevenLabs TTS: API key not configured'); + return false; + } + + // Load voices from ElevenLabs + try { + this.reportProgress(50, 'Loading ElevenLabs voices'); + await this.loadVoices(apiKey); + } catch (error) { + console.error('ElevenLabs TTS: Failed to load voices:', error); + return false; + } + + // Load preferences + const preferredVoice = persistenceManager.getPreference('elevenlabs', 'voice', this.voiceOptions.voice); + if (preferredVoice) { + this.voiceOptions.voice = preferredVoice; + } + + const preferredModel = persistenceManager.getPreference('elevenlabs', 'model', this.voiceOptions.model); + if (preferredModel) { + this.voiceOptions.model = preferredModel; + } + + const preferredSpeed = persistenceManager.getPreference('elevenlabs', 'speed', this.voiceOptions.speed); + if (typeof preferredSpeed === 'number') { + this.voiceOptions.speed = preferredSpeed; + } + + this.isReady = true; + this.reportProgress(100, 'ElevenLabs TTS initialized'); + return true; + } catch (error) { + console.error('ElevenLabs TTS: Initialization error:', error); + this.isReady = false; + return false; + } + } + + /** + * Get the default API base URL for ElevenLabs + * @returns {string} - Default API base URL + */ + getDefaultApiBaseUrl() { + return 'https://api.elevenlabs.io/v1'; + } + + /** + * Load available voices from ElevenLabs API + * @param {string} apiKey - API key for authentication + * @returns {Promise} - Resolves with success status + */ + async loadVoices(apiKey) { + // Set default voices that will be used if API call fails + this.voices = [ + { id: 'pNInz6obpgDQGcFmaJgB', name: 'Rachel', language: 'en' }, + { id: '21m00Tcm4TlvDq8ikWAM', name: 'Adam', language: 'en' }, + { id: 'AZnzlk1XvdvUeBnXmlld', name: 'Antoni', language: 'en' }, + { id: 'EXAVITQu4vr4xnSDxMaL', name: 'Bella', language: 'en' }, + { id: 'ErXwobaYiN019PkySvjV', name: 'Daniel', language: 'en' } + ]; + + // Only load from API if we have an API key + if (!apiKey) { + return true; + } + + try { + const response = await fetch(`${this.apiBaseUrl}/voices`, { + method: 'GET', + headers: { + 'xi-api-key': apiKey, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.error(`ElevenLabs TTS: API error: ${response.status} ${response.statusText}`); + return true; // Use defaults, but don't fail initialization + } + + const data = await response.json(); + + if (data && data.voices && Array.isArray(data.voices)) { + // Transform API response to our internal format + this.voices = data.voices.map(voice => ({ + id: voice.voice_id, + name: voice.name, + language: 'en', // ElevenLabs doesn't provide language info + preview: voice.preview_url + })); + + return true; + } + } catch (error) { + console.error('ElevenLabs TTS: Error loading voices:', error); + } + + // If API call failed, we still return true since we have default voices + return true; + } + + /** + * Select a voice for the given locale + * @param {string} locale - Locale code + * @returns {boolean} - Success status + */ + selectVoiceForLocale(locale) { + if (!this.voices || this.voices.length === 0) { + return this.selectDefaultVoice(); + } + + // ElevenLabs doesn't provide language info for voices + // Simply use the first voice as default + return this.selectDefaultVoice(); + } + + /** + * Generate speech audio data using ElevenLabs API + * @param {string} text - Text to generate speech for + * @returns {Promise} - Audio data object + */ + async generateSpeechAudio(text) { + // Don't attempt to call the API if no API key is set or text is empty + if (!text || !this.apiKey) { + return { success: false, reason: 'missing_api_key_or_text' }; + } + + try { + // Process the text + const processedText = this.preprocessText(text); + + // Create request payload + const payload = { + text: processedText, + model_id: this.voiceOptions.model || 'eleven_multilingual_v2', + voice_settings: { + stability: 0.5, + similarity_boost: 0.75, + style: 0.0, + use_speaker_boost: true, + speed: this.voiceOptions.speed || 1.0 + } + }; + + // Make API request + const response = await fetch(`${this.apiBaseUrl}/text-to-speech/${this.voiceOptions.voice}?optimize_streaming_latency=0`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'xi-api-key': this.apiKey, + 'Accept': 'audio/wav' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + // Get audio blob from response + const audioBlob = await response.blob(); + + // Convert to array buffer for consistency with other modules + const arrayBuffer = await audioBlob.arrayBuffer(); + + return { + success: true, + audioData: arrayBuffer + }; + } catch (error) { + console.error('ElevenLabs TTS: Error generating speech:', error); + return { + success: false, + reason: 'api_error', + error: error.message + }; + } + } + + /** + * Set voice options + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + // Call parent method for common options + if (options.voice) { + this.voiceOptions.voice = options.voice; + + // Save voice preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'elevenlabs_voice', options.voice); + } + } + + if (typeof options.speed === 'number') { + this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); + } + + // Handle ElevenLabs-specific options + if (options.model) { + this.voiceOptions.model = options.model; + + // Save model preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'elevenlabs_model', options.model); + } + } + } +} + +// Register the module with the module registry +// Module registry MUST be accessed via window, not direct import +if (window.moduleRegistry) { + try { + // Create instance first, then register it + const elevenLabsTTSModule = new ElevenLabsTTSModule(); + window.moduleRegistry.register(elevenLabsTTSModule); + console.log('ElevenLabs TTS Module registered successfully'); + } catch (err) { + console.error('Failed to register ElevenLabs TTS Module:', err); + } +} else { + console.error('Module registry not available when attempting to register ElevenLabs TTS Module'); +} diff --git a/public/js/kokoro-handler.js b/public/js/kokoro-handler.js index 538bb78..bfa03ca 100644 --- a/public/js/kokoro-handler.js +++ b/public/js/kokoro-handler.js @@ -425,7 +425,7 @@ export class KokoroHandler extends TTSHandler { try { const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { - persistenceManager.setPreference('tts-voice-kokoro', foundVoice.id); + persistenceManager.updatePreference('tts-voice-kokoro', foundVoice.id); } } catch (error) { console.error('Kokoro TTS: Error saving voice preference:', error); diff --git a/public/js/kokoro-tts-module.js b/public/js/kokoro-tts-module.js new file mode 100644 index 0000000..487bb77 --- /dev/null +++ b/public/js/kokoro-tts-module.js @@ -0,0 +1,657 @@ +/** + * 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', 'Kokoro TTS'); + + // 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; + + // Bind additional methods beyond those in TTSHandlerModule + this.bindMethods([ + 'handleIframeMessage', + 'setupVoiceFromPreferences', + 'generateSpeech', + 'speakPreloaded', + 'preprocessText', + 'pause', + 'resume', + 'getDefaultVoices' + ]); + } + + /** + * Initialize the Kokoro TTS module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + console.log('Kokoro TTS: Initializing'); + this.state = '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; + } + + // 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.state = 'ERROR'; + break; + + case 'kokoro:speech-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.error) { + resolver.reject(new Error(event.data.error)); + } else { + resolver.resolve({ + success: true, + audioData: event.data.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 localization = this.getModule('localization'); + const locale = localization ? localization.getLocale() : null; + + // 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; + } + + /** + * Get available voices + * @returns {Array} - Array of voice objects + */ + async getVoices() { + // 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) { + if (!this.isReady || !preloadData || !preloadData.audioData) { + if (callback) { + callback({ 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.options.volume; + 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().catch(error => { + this.isSpeaking = false; + if (callback) { + callback({ 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.options.volume; + 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().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-speech', + text: processedText, + id, + voiceId: 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' } + ]; + } +} + +// Register the module with the module registry +// Module registry MUST be accessed via window, not direct import +if (window.moduleRegistry) { + try { + // Create instance first, then register it + const kokoroTTSModule = new KokoroTTSModule(); + window.moduleRegistry.register(kokoroTTSModule); + console.log('Kokoro TTS Module registered successfully'); + } catch (err) { + console.error('Failed to register Kokoro TTS Module:', err); + } +} else { + console.error('Module registry not available when attempting to register Kokoro TTS Module'); +} diff --git a/public/js/loader.js b/public/js/loader.js index 3dab175..ec2bf36 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -96,6 +96,12 @@ const ModuleLoader = (function() { */ async function loadModuleScripts() { + // Define dependency scripts that need to be loaded first but aren't modules themselves + const dependenciesToLoad = [ + { script: '/js/api-tts-module-base.js' }, // Abstract base class, not a module + { script: '/js/tts-handler-module.js' } // Abstract base class for TTS handlers, not a module + ]; + // Define modules with their weights const modulesToLoad = [ // Core functionality modules @@ -108,6 +114,10 @@ const ModuleLoader = (function() { // Audio and TTS modules { id: 'audio-manager', script: '/js/audio-manager.js', weight: 60 }, + { id: 'kokoro', script: '/js/kokoro-tts-module.js', weight: 65 }, + { id: 'browser', script: '/js/browser-tts-module.js', weight: 65 }, + { id: 'elevenlabs', script: '/js/elevenlabs-tts-module.js', weight: 65 }, + { id: 'openai', script: '/js/openai-tts-module.js', weight: 65 }, { id: 'tts-factory', script: '/js/tts-factory.js', weight: 70 }, // TTSFactory must be loaded before TTSPlayer { id: 'tts', script: '/js/tts-player.js', weight: 75 }, @@ -134,6 +144,10 @@ const ModuleLoader = (function() { createModuleListItem(module.id, getModuleNameFromId(module.id)); }); + // Load dependencies first + const loadDependencies = dependenciesToLoad.map(dependency => loadScript(dependency.script)); + await Promise.all(loadDependencies); + // Load each module script const loadPromises = modulesToLoad.map(module => loadScript(module.script)); return Promise.all(loadPromises); diff --git a/public/js/openai-tts-module.js b/public/js/openai-tts-module.js new file mode 100644 index 0000000..df772ca --- /dev/null +++ b/public/js/openai-tts-module.js @@ -0,0 +1,255 @@ +/** + * OpenAITTSModule + * Provides TTS via OpenAI API + */ +import { ApiTTSModuleBase } from './api-tts-module-base.js'; + +export class OpenAITTSModule extends ApiTTSModuleBase { + constructor() { + super('openai', 'OpenAI TTS'); + + // Voice options specific to OpenAI + this.voiceOptions = { + voice: 'alloy', // Default voice for OpenAI + model: 'tts-1', // Standard model + speed: 1.0, + response_format: 'mp3' // OpenAI supports mp3, opus, aac, and flac (not wav) + }; + + // Predefined voices - OpenAI has a fixed set + this.voices = [ + { id: 'alloy', name: 'Alloy', language: 'en' }, + { id: 'echo', name: 'Echo', language: 'en' }, + { id: 'fable', name: 'Fable', language: 'en' }, + { id: 'onyx', name: 'Onyx', language: 'en' }, + { id: 'nova', name: 'Nova', language: 'en' }, + { id: 'shimmer', name: 'Shimmer', language: 'en' } + ]; + } + + /** + * Get the default API base URL for OpenAI + * @returns {string} - Default API base URL + */ + getDefaultApiBaseUrl() { + return 'https://api.openai.com/v1'; + } + + /** + * Initialize the module + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(10, 'Initializing OpenAI TTS'); + + // Initialize parent + const parentInit = await super.initialize(); + if (!parentInit) { + console.error('OpenAI TTS: Parent initialization failed'); + return false; + } + + // Get required dependencies + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.error('OpenAI TTS: Required dependency persistence-manager not found'); + return false; + } + + // Check for API key + const apiKey = persistenceManager.getPreference('openai', 'api_key', ''); + if (!apiKey) { + console.error('OpenAI TTS: API key not configured'); + return false; + } + + // Set API key + this.apiKey = apiKey; + + // Load preferences + const preferredVoice = persistenceManager.getPreference('openai', 'voice', this.voiceOptions.voice); + if (preferredVoice) { + this.voiceOptions.voice = preferredVoice; + } + + const preferredModel = persistenceManager.getPreference('openai', 'model', this.voiceOptions.model); + if (preferredModel) { + this.voiceOptions.model = preferredModel; + } + + const preferredSpeed = persistenceManager.getPreference('openai', 'speed', this.voiceOptions.speed); + if (typeof preferredSpeed === 'number') { + this.voiceOptions.speed = preferredSpeed; + } + + // Setup available voices + this.voices = this.getAvailableVoices(); + + this.isReady = true; + this.reportProgress(100, 'OpenAI TTS initialized'); + return true; + } catch (error) { + console.error('OpenAI TTS: Initialization error:', error); + this.isReady = false; + return false; + } + } + + /** + * Load available voices + * @returns {Promise} - Resolves with success status + */ + async loadVoices() { + // OpenAI has a fixed set of voices, no need to fetch them + return true; + } + + /** + * Select a voice for the given locale + * @param {string} locale - Locale code + * @returns {boolean} - Success status + */ + selectVoiceForLocale(locale) { + // Extract language code from locale (e.g., 'en-US' -> 'en') + const langCode = locale.split('-')[0].toLowerCase(); + + // All OpenAI voices are English-based + // For English locales, we could customize the voice selection + // For non-English locales, we'll just use the default + + // In this simple implementation, we'll just use the default voice + return this.selectDefaultVoice(); + } + + /** + * Select a default voice + * @returns {boolean} - Success status + */ + selectDefaultVoice() { + this.voiceOptions.voice = 'alloy'; + return true; + } + + /** + * Generate speech audio data using OpenAI API + * @param {string} text - Text to generate speech for + * @returns {Promise} - Audio data object + */ + async generateSpeechAudio(text) { + if (!text || !this.apiKey) { + return { + success: false, + reason: 'missing_api_key_or_text' + }; + } + + try { + // Process the text + const processedText = this.preprocessText(text); + + // Create request payload + const payload = { + model: this.voiceOptions.model || 'tts-1', + input: processedText, + voice: this.voiceOptions.voice || 'alloy', + response_format: this.voiceOptions.response_format || 'mp3', + speed: this.voiceOptions.speed || 1.0 + }; + + // Make API request + const response = await fetch(`${this.apiBaseUrl}/audio/speech`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error: ${response.status} ${response.statusText} - ${errorText}`); + } + + // Get audio blob from response + const audioBlob = await response.blob(); + + // Convert to array buffer for consistency with other modules + const arrayBuffer = await audioBlob.arrayBuffer(); + + return { + success: true, + audioData: arrayBuffer + }; + } catch (error) { + console.error('OpenAI TTS: Error generating speech:', error); + return { + success: false, + reason: 'api_error', + error: error.message + }; + } + } + + /** + * Set voice options + * @param {Object} options - Voice options + */ + setVoiceOptions(options = {}) { + // Handle common options + if (options.voice) { + this.voiceOptions.voice = options.voice; + + // Save voice preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'openai_voice', options.voice); + } + } + + if (typeof options.speed === 'number') { + this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); + } + + // Handle OpenAI-specific options + if (options.model) { + this.voiceOptions.model = options.model; + + // Save the model preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'openai_model', options.model); + } + } + + if (options.response_format) { + // Ensure valid format: mp3, opus, aac, or flac + const validFormats = ['mp3', 'opus', 'aac', 'flac']; + if (validFormats.includes(options.response_format)) { + this.voiceOptions.response_format = options.response_format; + + // Save the format preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'openai_format', options.response_format); + } + } + } + } +} + +// Register the module with the module registry +// Module registry MUST be accessed via window, not direct import +if (window.moduleRegistry) { + try { + // Create instance first, then register it + const openAITTSModule = new OpenAITTSModule(); + window.moduleRegistry.register(openAITTSModule); + console.log('OpenAI TTS Module registered successfully'); + } catch (err) { + console.error('Failed to register OpenAI TTS Module:', err); + } +} else { + console.error('Module registry not available when attempting to register OpenAI TTS Module'); +} diff --git a/public/js/options-ui.js b/public/js/options-ui.js index a39ea56..084bef8 100644 --- a/public/js/options-ui.js +++ b/public/js/options-ui.js @@ -632,13 +632,19 @@ class OptionsUIModule extends BaseModule { * Show the options modal */ show() { - if (!this.modal) return; - - // Reload preferences before showing - this.loadPreferences(); - // Show modal - this.modal.style.display = 'flex'; + if (this.modal) { + this.modal.style.display = 'flex'; + + // Refresh TTS dropdown + this.populateTtsSystems(); + + // Make sure the UI reflects the current voice + this.populateVoices(); + + // Update API settings visibility based on the current selection + this.updateApiSettingsVisibility(); + } } /** @@ -669,6 +675,10 @@ class OptionsUIModule extends BaseModule { const ttsFactory = this.getModule('tts-factory'); if (!ttsFactory) return; + // Debug TTS handlers to see what's happening + console.log('Options UI: Debugging TTS handlers before populating dropdown'); + ttsFactory.debugTTSHandlers(); + // Clear existing options this.elements.ttsSystem.innerHTML = ''; @@ -1123,9 +1133,27 @@ class OptionsUIModule extends BaseModule { const available = event.detail?.available || false; - // Update the TTS options visibility + // DON'T hide the TTS section completely, as this prevents configuring API keys + // Instead, just mark it visually (we'll keep controls accessible) if (this.elements.ttsSection) { - this.elements.ttsSection.style.display = available ? 'block' : 'none'; + // Set a visual indicator that TTS is not working, but keep it visible + this.elements.ttsSection.classList.toggle('tts-unavailable', !available); + // Add status message if not available + if (!available && !this.elements.ttsUnavailableMessage) { + const statusDiv = document.createElement('div'); + statusDiv.className = 'tts-status-message'; + statusDiv.innerHTML = 'TTS Unavailable: Check logs for details. You can still configure API keys below.'; + statusDiv.style.color = '#ca3c3c'; + statusDiv.style.padding = '5px 0'; + statusDiv.style.marginBottom = '10px'; + this.elements.ttsUnavailableMessage = statusDiv; + // Insert at the top of the TTS section + this.elements.ttsSection.insertBefore(statusDiv, this.elements.ttsSection.firstChild); + } else if (available && this.elements.ttsUnavailableMessage) { + // Remove the message if TTS becomes available + this.elements.ttsUnavailableMessage.remove(); + this.elements.ttsUnavailableMessage = null; + } } // Update the TTS system dropdown diff --git a/public/js/tts-factory.js b/public/js/tts-factory.js index 2721c98..c857601 100644 --- a/public/js/tts-factory.js +++ b/public/js/tts-factory.js @@ -1,13 +1,8 @@ /** * TTS Factory Module - * Manages TTS handler instances + * Manages TTS handler instances and coordinates TTS functionality */ 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 { /** @@ -29,12 +24,54 @@ class TTSFactoryModule extends BaseModule { this.storeName = 'audioCacheStore'; this.dbVersion = 1; this.currentCacheSize = 0; // Track current size in bytes - this.maxCacheSizeBytes = 1 * 1024 * 1024 * 1024; // 1 GB limit + this.maxCacheSizeBytes = 100 * 1024 * 1024; // 100 MB by default this.cacheInitialized = false; - - // Cache status indicator (could be used in UI later) - this.cacheStatus = 'initializing'; // initializing, ready, error - + this.cacheStatus = 'uninitialized'; // uninitialized, initializing, ready, error + + // Bind methods to this instance + this.bindMethods([ + 'initialize', + 'registerHandler', + 'initializeHandler', + 'setActiveHandler', + 'getHandler', + 'getActiveHandler', + 'getAvailableHandlers', + 'speak', + 'stop', + 'pause', + 'resume', + 'getVoices', + 'getPreference', + 'isSpeaking', + 'configure', + 'preloadSpeech', + 'generateSpeechHash', + 'speakPreloaded', + 'getCachedSpeech', + 'manageCacheSize', + 'cacheSpeech', + 'isSpeechCached', + '_initializeDB', + '_getDBItem', + '_putDBItem', + '_deleteDBItem', + '_calculateTotalCacheSize', + '_getAllDBItemsSortedByAccess', + '_getDBItemOnly', + '_generateHash', + 'initiatePreferredHandler', + 'attemptFallbackHandler', + 'initializeCache', + 'setupEvents', + 'reportProgress', + 'loadPreferences', + 'registerHandlers', + 'initializeHandlerSystem', + 'debugLogAllRegisteredModules', + 'debugTTSHandlers' // Added method + ]); + // Listen for kokoro:ready event document.addEventListener('kokoro:ready', (event) => { if (event.detail && typeof event.detail.success === 'boolean') { @@ -64,152 +101,98 @@ class TTSFactoryModule extends BaseModule { this.updateTTSAvailability(); } }); - - // Bind methods - this.bindMethods([ - 'registerHandler', - 'initializeHandler', - 'getHandler', - 'setActiveHandler', - 'getActiveHandler', - 'getAvailableHandlers', - 'speak', - 'stop', - 'pause', - 'resume', - 'getVoices', - 'getPreference', - 'isSpeaking', - 'configure', - 'preloadSpeech', - 'generateSpeechHash', - 'speakPreloaded', - 'getCachedSpeech', - 'manageCacheSize', - 'cacheSpeech', - 'isSpeechCached', - '_initializeDB', - '_getDBItem', - '_putDBItem', - '_deleteDBItem', - '_calculateTotalCacheSize', - '_getAllDBItemsSortedByAccess', - '_getDBItemOnly', - '_generateHash' - ]); } /** - * Initialize the module - * @returns {Promise} - Resolves with success status + * Initialize the TTS factory module + * @returns {Promise} - Promise resolves with initialization success */ async initialize() { try { - this.reportProgress(10, "Initializing TTS factory"); + console.log('TTS Factory: Initializing'); - // 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; - } - - 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.reportProgress(30, "Initializing handlers"); - - // Initialize all handlers in parallel - const initPromises = Object.keys(this.handlers).map(id => this.initializeHandler(id)); - await Promise.all(initPromises); - - this.reportProgress(60, "All handlers initialized"); - - // Get TTS preferences - const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false); - const preferredProvider = persistenceManager.getPreference('tts', 'provider', 'none'); - - 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) { - // Determine fallback order - Kokoro -> Browser -> None (API requires manual config) - const fallbackOrder = ['kokoro', 'browser']; - - // 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'); - - 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'); - } + // Initialize cache + this.reportProgress(20, 'Initializing TTS cache'); + const cacheInitialized = await this.initializeCache(); + if (cacheInitialized) { + console.log('TTS Factory: Cache initialized successfully'); } else { - console.log('TTS Factory: TTS is disabled in preferences'); + console.warn('TTS Factory: Cache initialization failed, continuing without cache'); } - // Determine overall TTS availability - // Any handler that's initialized should count towards availability - this.ttsAvailable = Object.values(this.initStatus).some(status => status === true); + // Load preferences + this.reportProgress(40, 'Loading TTS preferences'); + await this.loadPreferences(); - console.log('TTS Factory: Overall TTS availability:', this.ttsAvailable); - console.log('TTS Factory: Handler status:', this.initStatus); + // Check for TTS handlers + this.reportProgress(60, 'Finding TTS handlers'); + await this.registerHandlers(); - // Dispatch TTS availability event - window.dispatchEvent(new CustomEvent('tts:availability', { - detail: { available: this.ttsAvailable } - })); + // Set default status + this.ttsAvailable = false; - this.reportProgress(100, "TTS factory initialized"); - return true; // TTS is optional, so always return true + // Initialize preferred or fallback handler + this.reportProgress(80, 'Initializing TTS handler'); + await this.initializeHandlerSystem(); + + // Set up event handlers + this.setupEvents(); + + // Debug: Log all registered modules + this.debugLogAllRegisteredModules(); + + this.reportProgress(100, 'TTS Factory initialized'); + console.log(`TTS Factory: Initialization complete, TTS available: ${this.ttsAvailable}`); + + // To maintain backward compatibility, we always return true + // since TTS is now optional and the system should function without it + 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 + console.error('TTS Factory: Initialization error:', error); + return true; // Still return true for backward compatibility } } /** - * Register a TTS handler - * @param {string} id - Handler ID - * @param {Object} handler - TTS handler instance + * Register event handlers for TTS system */ - registerHandler(id, handler) { - if (!id || !handler) return; - this.handlers[id] = handler; + setupEvents() { + // Listen for TTS handler state changes + document.addEventListener('tts:handler-state-changed', (event) => { + if (event.detail && event.detail.handler) { + const handlerId = event.detail.handler; + const ready = event.detail.ready === true; + + console.log(`TTS Factory: Handler ${handlerId} reported state change, ready = ${ready}`); + + // Update handler initialization status + if (this.handlers[handlerId]) { + this.initStatus[handlerId] = ready; + this.updateTTSAvailability(); + + // If this is our active handler and it's no longer ready, try fallback + if (this.activeHandler === handlerId && !ready) { + console.warn(`TTS Factory: Active handler ${handlerId} is no longer ready, falling back`); + this.attemptFallbackHandler(); + } + } + } + }); + + // Listen for kokoro error events + document.addEventListener('kokoro:error', (event) => { + console.error('TTS Factory: Received kokoro error event:', event.detail); + if (this.handlers['kokoro']) { + this.initStatus['kokoro'] = false; + this.updateTTSAvailability(); + + // If kokoro was our active handler, try fallback + if (this.activeHandler === 'kokoro') { + console.warn('TTS Factory: Kokoro handler failed, falling back'); + this.attemptFallbackHandler(); + } + } + }); } /** @@ -219,113 +202,81 @@ class TTSFactoryModule extends BaseModule { */ async initializeHandler(id) { if (!this.handlers[id]) { - console.error(`TTS Factory: Handler ${id} not found`); + console.error(`TTS Factory: Handler ${id} not found, cannot initialize`); 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; + console.log(`TTS Factory: Initializing handler ${id}`); - 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`); + const handler = this.handlers[id]; + console.log(`TTS Factory: Handler ${id} object:`, handler); + + // Check if handler has initialize method + if (typeof handler.initialize !== 'function') { + console.error(`TTS Factory: Handler ${id} does not have an initialize method`); + this.initStatus[id] = false; + return false; } - return success; + // Call the handler's initialize method + console.log(`TTS Factory: Calling initialize method on ${id} handler`); + const result = await handler.initialize(); + console.log(`TTS Factory: ${id} initialize() returned:`, result); + + // Store initialization result + this.initStatus[id] = result; + + // Double-check the handler's isReady flag + // Note: some handlers may return true from initialize() before they're fully ready + // (e.g., Kokoro continues loading the model asynchronously) + if (result && typeof handler.isReady === 'boolean') { + console.log(`TTS Factory: Handler ${id} has isReady = ${handler.isReady}`); + + // If handler is still loading after initialize(), set up a listener + if (result === true && handler.isReady === false && handler.state === 'INITIALIZING') { + console.log(`TTS Factory: Handler ${id} is still initializing, waiting for completion`); + + // Wait up to 5 seconds for the handler to become ready or error out + const readyTimeout = setTimeout(() => { + console.warn(`TTS Factory: Handler ${id} did not become ready in time`); + // If the handler didn't explicitly fail, we'll still consider it as potentially available + }, 5000); + + // Check every second if the handler is ready + const checkInterval = setInterval(() => { + if (handler.isReady === true || handler.state === 'FINISHED') { + clearInterval(checkInterval); + clearTimeout(readyTimeout); + console.log(`TTS Factory: Handler ${id} is now ready`); + this.initStatus[id] = true; + this.updateTTSAvailability(); + } else if (handler.state === 'ERROR') { + clearInterval(checkInterval); + clearTimeout(readyTimeout); + console.warn(`TTS Factory: Handler ${id} failed to initialize (state=ERROR)`); + this.initStatus[id] = false; + this.updateTTSAvailability(); + } + }, 1000); + } else if (result === true && handler.isReady === false) { + // If handler says it's ready but didn't set its own isReady flag, set it + console.log(`TTS Factory: Setting ${id} isReady = true based on successful initialize()`); + handler.isReady = true; + } + } + + console.log(`TTS Factory: Handler ${id} initialized with result: ${result}`); + + return result; } catch (error) { console.error(`TTS Factory: Error initializing handler ${id}:`, error); + console.error(`TTS Factory: Handler ${id} stack trace:`, error.stack); 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 @@ -333,157 +284,400 @@ class TTSFactoryModule extends BaseModule { 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`); + // The key handlers we want to ALWAYS include in the dropdown for API configuration + const apiHandlerIds = ['elevenlabs', 'openai']; + + // First, add all API-based handlers to make sure they're always available in the UI + // even if they're not registered or initialized + for (const id of apiHandlerIds) { + // If the handler is registered in our handlers object, use it + if (this.handlers[id]) { + console.log(`TTS Factory: Adding API handler ${id} to available handlers list`); availableHandlers.push({ id: id, handler: this.handlers[id] }); + } else { + // If the handler isn't registered yet, still include it in the list + // This ensures API handlers always show up in the UI for configuration + console.log(`TTS Factory: Adding placeholder for API handler ${id} to available handlers list`); + availableHandlers.push({ + id: id, + handler: null + }); + } + } + + // Add Kokoro handler - it's not an API handler but we want it to always appear + if (this.handlers['kokoro']) { + console.log('TTS Factory: Adding Kokoro handler to available handlers list'); + availableHandlers.push({ + id: 'kokoro', + handler: this.handlers['kokoro'] + }); + } + + // Then add any other non-API handlers that are initialized/ready + for (const id in this.handlers) { + // Skip handlers we've already added + if (apiHandlerIds.includes(id) || id === 'kokoro') { + continue; + } + + const handler = this.handlers[id]; + + // Only include non-API handlers if they're properly initialized + const isAvailable = this.initStatus[id] === true || handler.isReady === true; + + if (handler && isAvailable) { + console.log(`TTS Factory: Adding non-API handler ${id} to available handlers list`); + // Check if this handler is already in the list + if (!availableHandlers.some(h => h.id === id)) { + availableHandlers.push({ + id: id, + handler: handler + }); + } } } 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`); + console.log(`TTS Factory: Found ${availableHandlers.length} available handlers:`, + availableHandlers.map(h => h.id).join(', ')); } return availableHandlers; } /** - * Speak text using the active TTS handler - * @param {string} text - Text to speak - * @param {Object} options - TTS options + * Report progress during initialization + * @param {number} progress - Progress percentage (0-100) + * @param {string} message - Progress message + */ + reportProgress(progress, message) { + // Report progress + if (this.progressCallback) { + this.progressCallback(progress, message); + } + } + + /** + * Load TTS preferences from persistence manager + */ + async loadPreferences() { + // Get the persistence manager for preferences + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + // Load speed preference + const savedSpeed = persistenceManager.getPreference('tts', 'speed'); + if (typeof savedSpeed === 'number') { + this.speed = savedSpeed; + console.log(`TTS Factory: Loaded speed preference: ${this.speed}`); + } + } + } + + /** + * Register TTS handlers from module registry + */ + async registerHandlers() { + // Access module registry directly through window + // This is correct architectural pattern - module registry is NOT a dependency + const moduleRegistry = window.moduleRegistry; + + if (!moduleRegistry) { + console.error('TTS Factory: Module registry not available via window.moduleRegistry'); + return; + } + + console.log('TTS Factory: Module registry found, scanning for TTS modules...'); + console.log('TTS Factory: All modules in registry:', Object.keys(moduleRegistry.modules).join(', ')); + + // Register handlers (in order of preference) + const handlers = [ + { id: 'kokoro', displayName: 'Kokoro TTS' }, + { id: 'browser', displayName: 'Browser TTS' }, + { id: 'elevenlabs', displayName: 'ElevenLabs TTS' }, + { id: 'openai', displayName: 'OpenAI TTS' } + ]; + + // Register each handler + for (const [index, handler] of handlers.entries()) { + try { + console.log(`TTS Factory: Attempting to get module '${handler.id}'`); + const module = moduleRegistry.getModule(handler.id); + + if (module) { + console.log(`TTS Factory: Successfully got module '${handler.id}'`, module); + console.log(`TTS Factory: Module class: ${module.constructor.name}, ID: ${module.id}`); + this.registerHandler(handler.id, module); + console.log(`TTS Factory: Registered handler ${handler.id}`); + } else { + console.warn(`TTS Factory: Module ${handler.id} not found in module registry. All registered modules: ${Object.keys(moduleRegistry.modules).join(', ')}`); + } + } catch (error) { + console.error(`TTS Factory: Error registering handler ${handler.id}:`, error); + } + } + } + + /** + * Initialize the preferred or fallback TTS handler + */ + async initializeHandlerSystem() { + // Get the preferred handler from persistence manager + const persistenceManager = this.getModule('persistence-manager'); + let preferredHandler = null; + + if (persistenceManager) { + preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); + console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`); + } + + // Try to initialize and set the preferred handler + if (preferredHandler && this.handlers[preferredHandler]) { + console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); + + // Try to initialize the preferred handler + const success = await this.initializeHandler(preferredHandler); + + if (success) { + console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`); + return await this.setActiveHandler(preferredHandler); + } else { + console.warn(`TTS Factory: Preferred handler ${preferredHandler} initialization failed, trying fallbacks`); + } + } + + // If we couldn't initialize the preferred handler, try fallbacks + return this.attemptFallbackHandler(); + } + + /** + * Attempt to initialize and set fallback handlers in order * @returns {Promise} - Success status */ - async speak(text, options = {}) { - if (!this.activeHandler) { - console.warn("No active TTS handler"); - return false; - } + async attemptFallbackHandler() { + // Fallback order: Kokoro -> Browser -> None + const fallbackOrder = ['kokoro', 'browser']; - 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 { - // 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); + // Try each fallback in order + for (const handlerId of fallbackOrder) { + if (this.handlers[handlerId]) { + console.log(`TTS Factory: Trying fallback handler: ${handlerId}`); - if (!audioData) { - throw new Error(`Failed to generate speech for text: ${text.substring(0, 20)}...`); + // Try to initialize this handler + const success = await this.initializeHandler(handlerId); + + if (success) { + console.log(`TTS Factory: Fallback handler ${handlerId} initialized successfully`); + return await this.setActiveHandler(handlerId); + } else { + console.warn(`TTS Factory: Fallback handler ${handlerId} initialization failed`); } - - // 3. Cache the Result - await this.cacheSpeech(hash, audioData); } + } + + // If all fallbacks failed, update TTS availability + console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable'); + this.activeHandler = null; + this.updateTTSAvailability(); + + // TTS is optional, so return true even if no handler is available + return true; + } + + /** + * Initialize the audio cache system + * @returns {Promise} - Success status + */ + async initializeCache() { + console.log('TTS Factory: Initializing cache'); + + if (this.cacheInitialized) { + console.log('TTS Factory: Cache already initialized'); + return true; + } + + try { + // Initialize IndexedDB for audio cache + await this._initializeDB(); - // 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.'); - } - + // Calculate current cache size + await this._calculateTotalCacheSize(); + + this.cacheInitialized = true; + console.log(`TTS Factory: Cache initialized, current size: ${this.currentCacheSize} bytes`); + return true; } catch (error) { - console.error(`TTSFactory: Error during speak process for hash ${hash}:`, error); + console.error('TTS Factory: Failed to initialize cache:', error); + // Cache is non-essential, continue without it + this.cacheInitialized = false; 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} - True if preload finished successfully (either generated or already cached). + * Register a TTS handler + * @param {string} id - Handler ID + * @param {Object} handler - TTS handler instance + */ + registerHandler(id, handler) { + if (!handler) { + console.warn(`TTS Factory: Cannot register null handler for id ${id}`); + return; + } + + console.log(`TTS Factory: Registering handler ${id}`); + this.handlers[id] = handler; + this.initStatus[id] = false; + } + + /** + * Set the active TTS handler + * @param {string} id - Handler ID + * @returns {boolean} - Success status + */ + async setActiveHandler(id) { + // Make sure the handler exists and is initialized + if (!this.handlers[id] || !this.initStatus[id]) { + console.error(`TTS Factory: Cannot set active handler to ${id} - not available`); + return false; + } + + console.log(`TTS Factory: Setting active handler to ${id}`); + + // Stop any current speech + if (this.activeHandler && this.handlers[this.activeHandler]) { + this.handlers[this.activeHandler].stop(); + } + + // Set the new active handler + this.activeHandler = id; + + // Save the preference + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'preferred_handler', id); + } + + // Dispatch event + const event = new CustomEvent('tts:handler:changed', { + detail: { handler: id } + }); + document.dispatchEvent(event); + + return true; + } + + /** + * Get the active TTS handler + * @returns {Object|null} - Active TTS handler instance or null if none active + */ + getActiveHandler() { + if (!this.activeHandler || !this.handlers[this.activeHandler]) { + return null; + } + return this.handlers[this.activeHandler]; + } + + /** + * Speak text using the active TTS handler + * @param {string} text - Text to speak + * @param {Object} options - TTS options + * @returns {boolean} - Success status + */ + speak(text, options = {}) { + // Check if we have an active handler + if (!this.activeHandler || !this.ttsAvailable) { + console.warn('TTS Factory: No active handler or TTS not available'); + return false; + } + + // Get the active handler + const handler = this.handlers[this.activeHandler]; + if (!handler) { + console.error('TTS Factory: Active handler not found'); + return false; + } + + try { + // Apply speed option if specified + const effectiveOptions = { ...options }; + if (typeof effectiveOptions.speed === 'undefined') { + effectiveOptions.speed = this.speed; + } + + // Call the handler's speak method + return handler.speak(text, result => { + // Forward speech completion event + document.dispatchEvent(new CustomEvent('tts:speechCompleted', { + detail: { success: result?.success === true, error: result?.error } + })); + }); + } catch (error) { + console.error('TTS Factory: Error speaking:', error); + return false; + } + } + + /** + * Preload speech for later playback + * @param {string} text - Text to preload + * @param {number} [priority=5] - Priority for preloading (1-10, higher is more important) + * @returns {Promise} - Preloaded speech data */ async preloadSpeech(text, priority = 5) { - if (!this.isAvailable || !this.activeHandler) { - return false; // Cannot preload if TTS is unavailable + // Check if we have an active handler + if (!this.activeHandler || !this.ttsAvailable) { + console.warn('TTS Factory: Cannot preload speech - no active handler or TTS not available'); + return { success: false, reason: 'no_active_handler' }; } - + + // Get the active handler const handler = this.handlers[this.activeHandler]; - if (!handler || !handler.isReady) { - console.warn(`TTSFactory: Active handler (${this.activeHandler}) not ready for preload.`); - return false; + if (!handler) { + return { success: false, reason: 'handler_not_found' }; } - - // 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 + // Generate a hash for this speech request + const hash = await this.generateSpeechHash(text); + + // Check if we have this speech cached + const cached = await this.getCachedSpeech(hash); + if (cached) { + 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, cached); + this.cacheHits++; + return 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 + + // 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) { + await this.cacheSpeech(hash, preloadData); + console.log(`TTS Factory: Added speech to cache for hash ${hash} (size: ${this.currentCacheSize}/${this.maxCacheSizeBytes})`); + } + + return preloadData; + } else { + console.warn(`TTS Factory: Handler ${this.activeHandler} does not support preloading`); + return null; } - 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; + console.error("TTS Factory: Error preloading speech:", error); + return null; } } @@ -492,12 +686,22 @@ class TTSFactoryModule extends BaseModule { * @returns {boolean} - Success status */ stop() { - if (!this.activeHandler) return false; + // Check if we have an active handler + if (!this.activeHandler || !this.ttsAvailable) { + return false; + } + // Get the active handler + const handler = this.handlers[this.activeHandler]; + if (!handler) { + return false; + } + + // Call the handler's stop method try { - return this.handlers[this.activeHandler].stop(); + return handler.stop(); } catch (error) { - console.error("Error stopping TTS:", error); + console.error('TTS Factory: Error stopping speech:', error); return false; } } @@ -536,28 +740,31 @@ class TTSFactoryModule extends BaseModule { * Get voices from the active handler * @returns {Array} - Array of voices */ - getVoices() { - // Get the active handler - const handler = this.getActiveHandler(); - + async getVoices() { // Check if we have an active handler - if (!handler) { - console.log('TTS Factory: No active handler, returning empty voices array'); + if (!this.activeHandler || !this.handlers[this.activeHandler]) { return []; } - // Get voices from the active handler - const voices = handler.getVoices(); - console.log(`TTS Factory: Retrieved ${voices ? voices.length : 0} voices from ${this.activeHandler}`); + const handler = this.handlers[this.activeHandler]; - // Check if we have any voices - if (!voices || voices.length === 0) { - console.warn(`TTS Factory: No voices retrieved from ${this.activeHandler} handler`); + try { + // Return voices from handler if it supports it + if (typeof handler.getVoices === 'function') { + const voices = await handler.getVoices(); + return voices || []; + } + + // If no getVoices method, try accessing voices property + if (Array.isArray(handler.voices)) { + return handler.voices; + } + + return []; + } catch (error) { + console.error('TTS Factory: Error getting voices:', error); return []; } - - // Return voices - return voices; } /** @@ -580,28 +787,55 @@ class TTSFactoryModule extends BaseModule { * @returns {boolean} - True if speaking, false otherwise */ isSpeaking() { - if (!this.activeHandler || !this.handlers[this.activeHandler]) { - return false; + // Check active handler first + if (this.activeHandler && this.handlers[this.activeHandler]) { + if (this.handlers[this.activeHandler].isSpeaking) { + return true; + } } - try { - return this.handlers[this.activeHandler].isSpeaking(); - } catch (error) { - console.error("Error checking speaking status:", error); - return false; - } + // Check all handlers + return Object.values(this.handlers).some(handler => + handler && typeof handler.isSpeaking === 'boolean' && handler.isSpeaking + ); } /** * Update overall TTS availability */ updateTTSAvailability() { - this.ttsAvailable = this.initStatus.kokoro || this.initStatus.browser; + // TTS is considered available if at least one handler is initialized + const wasAvailable = this.ttsAvailable; - // Dispatch TTS availability event - window.dispatchEvent(new CustomEvent('tts:availability', { - detail: { available: this.ttsAvailable } - })); + // Check if any handler is available (initialized and ready) + let anyHandlerAvailable = false; + for (const id in this.handlers) { + const handler = this.handlers[id]; + if (handler && this.initStatus[id] === true && handler.isReady === true) { + anyHandlerAvailable = true; + break; + } + } + + this.ttsAvailable = anyHandlerAvailable; + + console.log('TTS Factory: Availability updated:', this.ttsAvailable); + console.log('TTS Factory: Handler status:', JSON.stringify(this.initStatus)); + + // Handler details for debugging + for (const id in this.handlers) { + const handler = this.handlers[id]; + console.log(`TTS Factory: Handler ${id} status: initStatus=${this.initStatus[id]}, isReady=${handler ? handler.isReady : 'handler undefined'}`); + } + + // Only dispatch event if availability changed + if (wasAvailable !== this.ttsAvailable) { + // Notify the UI about TTS availability + const event = new CustomEvent('tts:availability', { + detail: { available: this.ttsAvailable } + }); + document.dispatchEvent(event); + } } /** @@ -610,40 +844,37 @@ class TTSFactoryModule extends BaseModule { * @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`); + if (!options || typeof options !== 'object') { + return; } - return true; + // Handle speed option + if (typeof options.speed === 'number') { + // Save speed setting + this.speed = Math.max(0.1, Math.min(3.0, options.speed)); + + // Save to preferences + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'speed', this.speed); + } + + // Update all handlers + for (const id in this.handlers) { + const handler = this.handlers[id]; + if (handler && typeof handler.setVoiceOptions === 'function') { + handler.setVoiceOptions({ speed: this.speed }); + } + } + } + + // Update UI that TTS settings have changed + document.dispatchEvent(new CustomEvent('tts:configured', { + detail: { + options: { speed: this.speed }, + activeHandler: this.activeHandler + } + })); } /** @@ -661,7 +892,7 @@ class TTSFactoryModule extends BaseModule { // Generate a hash for this speech request const hash = await this.generateSpeechHash(text); - // Check if we already have this audio in cache + // Check if we have this audio in cache const cachedData = await this.getCachedSpeech(hash); if (cachedData) { console.log(`TTS Factory: Using cached speech for hash ${hash} (hits: ${this.cacheHits}, misses: ${this.cacheMisses})`); @@ -702,21 +933,25 @@ class TTSFactoryModule extends BaseModule { * @returns {Promise} - Hash string */ async generateSpeechHash(text) { - if (!this.activeHandler) return null; + // Get the active handler for voice information + const handler = this.getActiveHandler(); - // 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; + // Include handler ID and voice options in the hash to ensure uniqueness across voices + let voiceInfo = ''; + if (handler && handler.voiceOptions && handler.voiceOptions.voice) { + // Use the voice ID or name to identify the voice + voiceInfo = handler.voiceOptions.voice.id || handler.voiceOptions.voice; + } - // Create a string to hash - const dataToHash = `${handlerId}_${voiceId}_${speed}_${text}`; + // Also include speed setting in the hash + const speed = this.speed || 1.0; + + // Create a composite key for hashing + const key = `${text}|${this.activeHandler}|${voiceInfo}|${speed}`; - // Use SubtleCrypto to create a SHA-256 hash if available try { const encoder = new TextEncoder(); - const data = encoder.encode(dataToHash); + const data = encoder.encode(key); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert to hex string @@ -725,16 +960,9 @@ class TTSFactoryModule extends BaseModule { 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); + console.error('TTS Factory: Error generating hash:', error); + // Simple fallback hash if crypto API fails + return key.replace(/[^a-z0-9]/gi, '').substring(0, 32); } } @@ -762,81 +990,65 @@ class TTSFactoryModule extends BaseModule { /** * Get cached speech data * @param {string} hash - Hash of the speech data - * @returns {Promise} - Cached speech data or null if not found + * @returns {Promise} - Cached speech data or null if not found */ async getCachedSpeech(hash) { if (!this.db || this.cacheStatus !== 'ready') { - console.warn("IndexedDB not ready, cannot get item."); + console.warn('TTS Factory: Cache not ready, cannot retrieve cached speech'); 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}`); + const item = await this._getDBItem(hash); + if (item && item.audioData) { + console.log(`TTS Factory: Found cached speech for hash ${hash}`); + return item.audioData; } - return data; } catch (error) { - console.error(`TTS Factory: Error getting cached speech for hash ${hash}:`, error); - return null; + console.error('TTS Factory: Error retrieving cached speech:', error); } + + return null; } /** * Add speech data to the cache * @param {string} hash - Hash of the speech data - * @param {Blob} audioData - The audio data to cache - * @returns {Promise} + * @param {ArrayBuffer} audioData - Audio data to cache + * @returns {Promise} - Success status */ async cacheSpeech(hash, audioData) { if (!this.db || this.cacheStatus !== 'ready') { - console.warn("IndexedDB not ready, cannot cache speech."); - return; + console.warn('TTS Factory: Cache not ready, cannot cache speech'); + return false; } - if (!(audioData instanceof Blob) || audioData.size === 0) { - console.warn("TTSFactory: Invalid audio data provided for caching."); - return; + + if (!audioData) { + console.error('TTS Factory: No audio data provided to cache'); + return false; } - - 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); - }); + // Make sure we have room in the cache + await this.manageCacheSize(audioData.byteLength); + + // Store the speech data + await this._putDBItem(hash, audioData); + + console.log(`TTS Factory: Cached speech for hash ${hash}`); + return true; } 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 + console.error('TTS Factory: Error caching speech:', error); + return false; } } /** * Manages the cache size, ensuring it doesn't exceed the limit using LRU. + * @param {number} [sizeToAdd] - Optional size to add to the cache before checking * @returns {Promise} */ - async manageCacheSize() { + async manageCacheSize(sizeToAdd = 0) { if (!this.db || this.cacheStatus !== 'ready') { console.warn("TTSFactory: Cache DB not ready for size management."); return; @@ -851,6 +1063,9 @@ class TTSFactoryModule extends BaseModule { this.currentCacheSize = await this._calculateTotalCacheSize(); console.log(`TTS Factory: Recalculated cache size: ${(this.currentCacheSize / (1024*1024)).toFixed(2)} MB`); + // Add the size to be added to the current size + this.currentCacheSize += sizeToAdd; + 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.`); @@ -975,7 +1190,7 @@ class TTSFactoryModule extends BaseModule { /** * 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} - The audio data Blob or null if not found. + * @returns {Promise} - The cached item object or null if not found. */ async _getDBItem(hash) { if (!this.db || this.cacheStatus !== 'ready') { @@ -1002,11 +1217,11 @@ class TTSFactoryModule extends BaseModule { 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); + resolve(result); }; updateRequest.onsuccess = () => { // console.log(`Updated lastAccessed for hash: ${hash}`); - resolve(result.data); + resolve(result); }; } else { resolve(null); // Not found @@ -1028,23 +1243,24 @@ class TTSFactoryModule extends BaseModule { /** * Adds or updates an item in the IndexedDB store. - * @param {object} item - The item object { hash: string, data: Blob, size: number, lastAccessed: number }. + * @param {string} hash - The key (hash) of the item to store. + * @param {ArrayBuffer} audioData - The audio data to cache. * @returns {Promise} */ - async _putDBItem(item) { + async _putDBItem(hash, audioData) { 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); + if (!hash || !audioData) { + console.error("Invalid item provided to _putDBItem:", hash, audioData); 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); + const request = store.put({ hash, audioData, size: audioData.byteLength, lastAccessed: Date.now() }); request.onerror = (event) => { console.error("Error putting item into IndexedDB:", event.target.error); @@ -1052,7 +1268,7 @@ class TTSFactoryModule extends BaseModule { }; request.onsuccess = () => { - // console.log(`Successfully put item with hash: ${item.hash}`); + // console.log(`Successfully put item with hash: ${hash}`); resolve(); }; @@ -1218,6 +1434,163 @@ class TTSFactoryModule extends BaseModule { } } + /** + * 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]; + } + + /** + * Attempt to initialize and set the preferred handler + * @returns {Promise} - Success status + */ + async initiatePreferredHandler() { + // Get the preferred handler from persistence manager + const persistenceManager = this.getModule('persistence-manager'); + let preferredHandler = null; + + if (persistenceManager) { + preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler'); + console.log(`TTS Factory: Preferred handler from settings: ${preferredHandler || 'none'}`); + } + + // Try to initialize and set the preferred handler + if (preferredHandler && this.handlers[preferredHandler]) { + console.log(`TTS Factory: Attempting to initialize preferred handler: ${preferredHandler}`); + + // Try to initialize the preferred handler + const success = await this.initializeHandler(preferredHandler); + + if (success) { + console.log(`TTS Factory: Preferred handler ${preferredHandler} initialized successfully`); + return await this.setActiveHandler(preferredHandler); + } else { + console.warn(`TTS Factory: Preferred handler ${preferredHandler} initialization failed, trying fallbacks`); + } + } + + // If we couldn't initialize the preferred handler, try fallbacks + return this.attemptFallbackHandler(); + } + + /** + * Attempt to initialize and set fallback handlers in order + * @returns {Promise} - Success status + */ + async attemptFallbackHandler() { + // Fallback order: Kokoro -> Browser -> None + const fallbackOrder = ['kokoro', 'browser']; + + // Try each fallback in order + for (const handlerId of fallbackOrder) { + if (this.handlers[handlerId]) { + console.log(`TTS Factory: Trying fallback handler: ${handlerId}`); + + // Try to initialize this handler + const success = await this.initializeHandler(handlerId); + + if (success) { + console.log(`TTS Factory: Fallback handler ${handlerId} initialized successfully`); + return await this.setActiveHandler(handlerId); + } else { + console.warn(`TTS Factory: Fallback handler ${handlerId} initialization failed`); + } + } + } + + // If all fallbacks failed, update TTS availability + console.warn('TTS Factory: All handlers failed to initialize, TTS will be unavailable'); + this.activeHandler = null; + this.updateTTSAvailability(); + + // TTS is optional, so return true even if no handler is available + return true; + } + + /** + * Debug TTS handlers and their status + * This will log detailed information about all TTS handlers + */ + debugTTSHandlers() { + console.log('===== DEBUG TTS HANDLERS START ====='); + + // Log all registered handlers + console.log('Registered Handlers:'); + for (const id in this.handlers) { + const handler = this.handlers[id]; + const isInitialized = !!this.initStatus[id]; + const isReady = handler && handler.isReady; + const isApiHandler = ['elevenlabs', 'openai', 'kokoro'].includes(id); + + console.log(`Handler ID: ${id}`); + console.log(` - Handler Exists: ${!!handler}`); + console.log(` - Is API Handler: ${isApiHandler}`); + console.log(` - Init Status: ${isInitialized}`); + console.log(` - Is Ready: ${isReady}`); + console.log(` - Would Include in UI: ${isApiHandler || isInitialized || isReady}`); + + // Check handler properties + if (handler) { + console.log(` - Type: ${handler.constructor.name}`); + console.log(` - Module ID: ${handler.id}`); + } + } + + // Check what getAvailableHandlers is returning + const availableHandlers = this.getAvailableHandlers(); + console.log('\ngetAvailableHandlers() returns:', availableHandlers.map(h => h.id)); + + // Check module registry + console.log('\nModules in Registry:'); + const registry = window.moduleRegistry; + if (registry && registry.modules) { + // Find all TTS-related modules + const ttsModules = Object.keys(registry.modules).filter(id => { + const module = registry.modules[id]; + return module && ( + id === 'tts-factory' || + id === 'kokoro' || + id === 'browser' || + id === 'elevenlabs' || + id === 'openai' + ); + }); + + ttsModules.forEach(id => { + const module = registry.modules[id]; + console.log(` - Module ID: ${id}`); + console.log(` - Type: ${module.constructor.name}`); + console.log(` - Is Initialized: ${module.isInitialized}`); + }); + } else { + console.log(' Module Registry not available'); + } + + console.log('===== DEBUG TTS HANDLERS END ====='); + } + + /** + * For debugging: Log all registered modules in the registry + */ + debugLogAllRegisteredModules() { + console.log('=== DEBUG: All registered modules ==='); + if (window.moduleRegistry && window.moduleRegistry.modules) { + const moduleIds = Object.keys(window.moduleRegistry.modules); + moduleIds.forEach(id => { + const module = window.moduleRegistry.modules[id]; + console.log(`Module [${id}]: ${module.constructor.name}`); + }); + console.log(`Total modules: ${moduleIds.length}`); + } else { + console.log('Module registry not available'); + } + console.log('=== END DEBUG ==='); + } + /** * Clean up when module is disposed */ @@ -1240,10 +1613,13 @@ class TTSFactoryModule extends BaseModule { } } -// Create the singleton instance +// Create module instance const TTSFactory = new TTSFactoryModule(); -// Register with the module registry +// Import the moduleRegistry for initial registration +// Note: This is the only place where direct import is appropriate, as we need to +// register the module before it can use the dependency system +import { moduleRegistry } from './module-registry.js'; moduleRegistry.register(TTSFactory); // Export the module diff --git a/public/js/tts-handler-module.js b/public/js/tts-handler-module.js new file mode 100644 index 0000000..587c252 --- /dev/null +++ b/public/js/tts-handler-module.js @@ -0,0 +1,202 @@ +/** + * TTSHandlerModule Base Class + * Base class for all TTS handler modules + */ +import { BaseModule } from './base-module.js'; + +export class TTSHandlerModule extends BaseModule { + constructor(id, name) { + super(id, name); + + // Common TTS handler properties + this.isReady = false; + this.isSpeaking = false; + this.currentUtterance = null; + this.voices = []; + this.currentVoice = null; + this.defaultVoice = null; + this.speechRate = 1.0; + this.pitch = 1.0; + this.volume = 1.0; + + // Common dependencies for TTS handlers + this.dependencies = ['persistence-manager', 'localization']; + + // Bind common methods + this.bindMethods([ + 'speak', + 'stop', + 'getVoices', + 'setVoice', + 'configure', + 'generateSpeech' + ]); + } + + /** + * Get the handler ID + * @returns {string} - The handler ID + */ + getId() { + return this.id; + } + + /** + * Initialize the TTS handler + * @returns {Promise} - Resolves with success status + */ + async initialize() { + try { + this.reportProgress(20, `Initializing ${this.name}`); + + // Check for required dependencies + const persistenceManager = this.getModule('persistence-manager'); + if (!persistenceManager) { + console.error(`${this.name}: Persistence Manager dependency not found`); + return false; + } + + // Load preferences + this.loadPreferences(persistenceManager); + + // Set up event listeners + this.setupEventListeners(); + + return true; + } catch (error) { + console.error(`${this.name}: Initialization error`, error); + return false; + } + } + + /** + * Load preferences from persistence manager + * @param {Object} persistenceManager - The persistence manager module + */ + loadPreferences(persistenceManager) { + // Load common preferences + this.speechRate = persistenceManager.getPreference('tts', 'rate', 1.0); + this.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0); + this.volume = persistenceManager.getPreference('tts', 'volume', 1.0); + } + + /** + * Set up common event listeners + */ + setupEventListeners() { + // To be implemented by subclasses if needed + } + + /** + * Check if the handler is ready + * @returns {boolean} - Whether the handler is ready + */ + isHandlerReady() { + return this.isReady; + } + + /** + * Check if the handler is currently speaking + * @returns {boolean} - Whether the handler is speaking + */ + isSpeakingNow() { + return this.isSpeaking; + } + + /** + * Get available voices + * @returns {Promise} - Resolves with array of voice objects + */ + async getVoices() { + return this.voices; + } + + /** + * Set the voice to use + * @param {string} voiceId - Voice identifier + * @returns {boolean} - Success status + */ + setVoice(voiceId) { + // To be implemented by subclasses + return false; + } + + /** + * Configure TTS parameters + * @param {Object} options - Configuration options + * @returns {boolean} - Success status + */ + configure(options) { + let changed = false; + + if (options.voice && options.voice !== this.currentVoice) { + this.setVoice(options.voice); + changed = true; + } + + if (options.speed && options.speed !== this.speechRate) { + this.speechRate = options.speed; + changed = true; + } + + if (options.pitch && options.pitch !== this.pitch) { + this.pitch = options.pitch; + changed = true; + } + + if (options.volume && options.volume !== this.volume) { + this.volume = options.volume; + changed = true; + } + + // Save preferences if changed + if (changed) { + const persistenceManager = this.getModule('persistence-manager'); + if (persistenceManager) { + persistenceManager.updatePreference('tts', 'rate', this.speechRate); + persistenceManager.updatePreference('tts', 'pitch', this.pitch); + persistenceManager.updatePreference('tts', 'volume', this.volume); + if (this.currentVoice) { + persistenceManager.updatePreference('tts', `${this.id}_voice`, this.currentVoice); + } + } + } + + return changed; + } + + /** + * Speak text + * @param {string} text - Text to speak + * @param {Function} callback - Callback for when speech completes + * @returns {boolean} - Success status + */ + speak(text, callback) { + // To be implemented by subclasses + console.error(`${this.name}: speak() method not implemented`); + if (callback) { + setTimeout(() => callback({ success: false, reason: 'not_implemented' }), 0); + } + return false; + } + + /** + * Stop speaking + * @returns {boolean} - Success status + */ + stop() { + // To be implemented by subclasses + return false; + } + + /** + * Generate speech audio data + * @param {string} text - Text to generate speech for + * @param {Object} options - Generation options + * @returns {Promise} - Resolves with audio data + */ + async generateSpeech(text, options = {}) { + // To be implemented by subclasses + return { success: false, reason: 'not_implemented' }; + } +} diff --git a/public/kokoro-loader.html b/public/kokoro-loader.html index 3d35333..436c58d 100644 --- a/public/kokoro-loader.html +++ b/public/kokoro-loader.html @@ -59,83 +59,28 @@ } } - // Create a global object to store Kokoro instance + // Create a simple loader object to handle the Kokoro instance window.KokoroLoader = { - loaded: false, - error: null, instance: null, kokoroTTS: null, - voices: null, - callbacks: [], - progress: 0, - progressMessage: 'Initializing...', - - // Register a callback for when Kokoro is loaded - onLoad: function(callback) { - if (this.loaded) { - callback(this.instance); - } else if (this.error) { - callback(null, this.error); - } else { - this.callbacks.push(callback); - } - }, + initialized: false, // Update progress updateProgress: function(progress, message) { - this.progress = progress; - this.progressMessage = message || 'Loading...'; const progressPercent = Math.round(progress * 100); - document.getElementById('status').textContent = `${this.progressMessage} (${isNaN(progressPercent) ? 0 : progressPercent}%)`; - log(`Progress: ${this.progressMessage} (${isNaN(progressPercent) ? 0 : progressPercent}%)`); + document.getElementById('status').textContent = `${message} (${progressPercent}%)`; + log(`Progress: ${message} (${progressPercent}%)`); - // Notify parent window - if (window.parent !== window) { + // Only notify parent if progress is valid + if (progress !== undefined && !isNaN(progress) && window.parent !== window) { window.parent.postMessage({ type: 'kokoro-progress', - progress: isNaN(progress) ? 0 : progress, - message: this.progressMessage + progress: progress, + message: message }, '*'); } }, - // Get default voices - getDefaultVoices: function() { - 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' } - ]; - }, - // Initialize Kokoro init: async function() { try { @@ -144,77 +89,56 @@ // Store the KokoroTTS class this.kokoroTTS = KokoroTTS; - log('Kokoro library loaded successfully', 'success'); + log('Kokoro library loaded', 'success'); this.updateProgress(0.3, 'Initializing Kokoro model...'); // Initialize the model const model_id = "onnx-community/Kokoro-82M-v1.0-ONNX"; this.instance = await this.kokoroTTS.from_pretrained(model_id, { dtype: "q8", // Use quantized model for better performance - device: "wasm", // Use WebAssembly for compatibility + device: "webgpu", // Use WebGL for better performance progress_callback: (progress) => { - // Map progress from 0-1 to 30-90 + // Skip progress updates if progress is NaN/undefined (cache loading) + if (progress === undefined || isNaN(progress)) { + log('Model loaded from cache', 'info'); + return; + } + + // Map progress from 0-1 to 30-90% const mappedProgress = 0.3 + (progress * 0.6); this.updateProgress(mappedProgress, `Loading Kokoro model: ${Math.round(progress * 100)}%`); } }); - // Fetch available voices - log('Fetching available voices...'); - this.updateProgress(0.8, 'Fetching voices...'); - - // Use default voices directly since the list_voices method is unreliable - log('Using predefined voice list instead of attempting to fetch from model'); - this.voices = this.getDefaultVoices(); - log(`Using ${this.voices.length} predefined voices`, 'success'); - - log('Testing Kokoro with a simple text'); - this.updateProgress(0.95, 'Testing Kokoro...'); - - // Test with a simple text - // Use the first available voice for testing - const testVoice = this.voices && this.voices.length > 0 ? this.voices[0].id : 'af_heart'; - await this.instance.generate('Test', { voice: testVoice }); - - log('Kokoro initialized successfully', 'success'); - this.loaded = true; + log('Model initialized successfully', 'success'); this.updateProgress(1.0, 'Kokoro ready'); + this.initialized = true; - // Notify parent window + // Notify parent window of successful initialization if (window.parent !== window) { log('Notifying parent window of successful initialization'); window.parent.postMessage({ - type: 'kokoro-ready', - success: true, - voices: this.voices + type: 'kokoro:ready', + success: true }, '*'); } - // Call all callbacks - log(`Calling ${this.callbacks.length} registered callbacks`); - this.callbacks.forEach(callback => callback(this.instance)); - document.getElementById('status').textContent = 'Kokoro loaded and ready!'; } catch (error) { const errorMsg = error.message || 'Unknown error'; log(`Error initializing Kokoro: ${errorMsg}`, 'error'); console.error('Error initializing Kokoro:', error); - this.error = error; // Notify parent window if (window.parent !== window) { log('Notifying parent window of initialization failure'); window.parent.postMessage({ - type: 'kokoro-ready', + type: 'kokoro:ready', success: false, error: errorMsg }, '*'); } - // Call all callbacks with error - log(`Calling ${this.callbacks.length} registered callbacks with error`); - this.callbacks.forEach(callback => callback(null, error)); - document.getElementById('status').textContent = `Error loading Kokoro: ${errorMsg}`; } } @@ -232,9 +156,20 @@ const data = event.data; - if (data.type === 'kokoro-generate') { + if (data.type === 'kokoro:initialize') { + // If we're already initialized, just send the ready message + if (window.KokoroLoader.initialized) { + log('Already initialized, sending ready message'); + window.parent.postMessage({ + type: 'kokoro:ready', + success: true + }, '*'); + } + // Otherwise init() will handle sending the ready message when done + } + else if (data.type === 'kokoro-generate') { // Generate speech in a non-blocking way - if (!window.KokoroLoader.loaded) { + if (!window.KokoroLoader.initialized || !window.KokoroLoader.instance) { log(`Cannot process generation request ${data.id}: Kokoro not loaded`, 'error'); window.parent.postMessage({ type: 'kokoro-generated',