/** * API TTS Handler * Provides TTS via external APIs (e.g., ElevenLabs) */ import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class ApiTTSHandler extends TTSHandler { constructor() { super(); this.id = 'api'; this.name = 'API TTS Handler'; // Voice options this.voiceOptions = { voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs model: 'eleven_multilingual_v2', // Use the multilingual model for better German speed: 1.0 }; // State this.available = false; this.isReady = false; this.currentAudio = null; this.preloadCache = new Map(); // API endpoint this.apiEndpoint = '/api/tts'; // Dependencies this.dependencies = ['localization', 'persistence-manager']; // Bind methods this.bindMethods([ 'initialize', 'speak', 'speakPreloaded', 'preloadSpeech', 'stop', 'isAvailable', 'getId', 'getVoices', 'setVoiceOptions', 'getModule', 'setupVoiceFromPreferences', 'selectVoiceForLocale', 'selectDefaultVoice' ]); } /** * Get a module from the registry * @param {string} moduleId - ID of the module to get * @returns {Object|null} - The module or null if not found */ getModule(moduleId) { return moduleRegistry.getModule(moduleId); } /** * Initialize the API TTS handler * @param {Function} progressCallback - Callback for progress updates * @returns {Promise} - Resolves with success status */ async initialize(progressCallback = null) { try { if (progressCallback) { progressCallback(10, "Initializing API TTS Handler"); } // Check for required dependencies const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); if (!localization) { console.error("API TTS: Localization module not found, required dependency missing"); if (progressCallback) { progressCallback(100, "API TTS initialization failed - missing localization"); } return false; } if (!persistenceManager) { console.error("API TTS: Persistence Manager module not found, required dependency missing"); if (progressCallback) { progressCallback(100, "API TTS initialization failed - missing persistence manager"); } return false; } // Create audio element this.audioElement = new Audio(); if (progressCallback) { progressCallback(30, "Loading voices"); } // Load available voices try { await this.loadVoices(); } catch (error) { console.warn("API TTS: Failed to load voices, continuing with initialization", error); // Continue initialization even if voice loading fails } if (progressCallback) { progressCallback(50, "Setting up voice preferences"); } // Set up voice based on preferences and locale try { const voiceSetupSuccess = await this.setupVoiceFromPreferences(); if (!voiceSetupSuccess) { console.warn("API TTS: Could not set up voice from preferences, using default"); } } catch (error) { console.warn("API TTS: Error setting up voice preferences", error); // Continue initialization even if voice setup fails } // Check if API is available by making a test request try { if (progressCallback) { progressCallback(70, "Checking API availability"); } const response = await fetch(`${this.apiEndpoint}/voices`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { console.warn(`API TTS: API endpoint not available (${response.status} ${response.statusText}). Will use fallback.`); this.available = false; this.isReady = true; // Still mark as ready, just not available if (progressCallback) { progressCallback(100, "API TTS unavailable, using fallback"); } // Return true to indicate the module initialized successfully // even though the API is not available return true; } const data = await response.json(); if (progressCallback) { progressCallback(90, "API TTS available"); } // Check for German voices and set default if available if (data && data.voices && Array.isArray(data.voices)) { const germanVoices = data.voices.filter(voice => voice.name.toLowerCase().includes('german') || voice.language === 'de' || voice.language === 'de-DE' ); if (germanVoices.length > 0) { // Use the first German voice as default this.voiceOptions.voice = germanVoices[0].id; console.log(`API TTS: Found German voice: ${germanVoices[0].name} (${germanVoices[0].id})`); } } this.available = true; this.isReady = true; if (progressCallback) { progressCallback(100, "API TTS Handler ready"); } return true; } catch (error) { console.warn("API TTS: Error checking API availability:", error); // Mark as ready but not available this.available = false; this.isReady = true; if (progressCallback) { progressCallback(100, "API TTS unavailable due to error"); } // Return true to indicate the module initialized successfully // even though the API is not available return true; } } catch (error) { console.error("Error initializing API TTS Handler:", error); // Mark as ready but not available this.available = false; this.isReady = true; if (progressCallback) { progressCallback(100, "API TTS initialization failed"); } // Return true to indicate the module initialized successfully // even though there was an error return true; } } /** * Set up voice based on preferences and locale * @returns {Promise} - Resolves with success status */ async setupVoiceFromPreferences() { try { // Get localization and persistence manager modules const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); // Both modules should be available as we checked in initialize if (!localization || !persistenceManager) { console.error("API TTS: Required modules not available for voice setup"); return this.selectDefaultVoice(); } // Get current locale and preferred voice const currentLocale = localization.getLocale(); const preferredVoice = persistenceManager.getPreference('tts', 'voice', ''); // If we have a preferred voice, use it if (preferredVoice) { this.voiceOptions.voice = preferredVoice; console.log(`API TTS: Using preferred voice: ${preferredVoice}`); return true; } // Otherwise select based on locale console.log(`API TTS: No preferred voice, selecting for locale: ${currentLocale}`); return this.selectVoiceForLocale(currentLocale); } catch (error) { console.error("API TTS: Error setting up voice from preferences:", error); return this.selectDefaultVoice(); } } /** * Load available voices from API * @returns {Promise} - Resolves with success status */ async loadVoices() { try { // Fetch available voices from API const response = await fetch(`${this.apiEndpoint}/voices`); if (!response.ok) { console.warn(`API TTS: Failed to load voices - ${response.status} ${response.statusText}`); return false; } const data = await response.json(); if (!data.voices || !Array.isArray(data.voices)) { console.warn("API TTS: Invalid voice data received"); return false; } this.voices = data.voices; console.log(`API TTS: Loaded ${this.voices.length} voices`); return true; } catch (error) { console.error("Error loading API TTS voices:", error); return false; } } /** * Select a voice for the given locale * @param {string} locale - Locale code * @returns {boolean} - Success status */ selectVoiceForLocale(locale) { if (!locale || this.voices.length === 0) { return this.selectDefaultVoice(); } // Normalize locale const normalizedLocale = locale.toLowerCase(); // Try to find a voice for the exact locale let matchingVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase() === normalizedLocale ); // If no exact match, try to find a voice for the language part if (!matchingVoice) { const langPart = normalizedLocale.split('-')[0]; matchingVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith(langPart) ); } // If still no match, use default if (!matchingVoice) { return this.selectDefaultVoice(); } // Set the matching voice this.voiceOptions.voice = matchingVoice.id; console.log(`API TTS: Selected voice ${matchingVoice.name} for locale ${locale}`); // Update preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'voice', matchingVoice.id || matchingVoice.name); } return true; } /** * Select a default voice * @returns {boolean} - Success status */ selectDefaultVoice() { if (this.voices.length === 0) { console.warn("API TTS: No voices available for default selection"); return false; } // Prefer English voices if available const englishVoice = this.voices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith('en') ); if (englishVoice) { this.voiceOptions.voice = englishVoice.id; console.log(`API TTS: Selected default English voice ${englishVoice.name}`); } else { // Otherwise use the first available voice this.voiceOptions.voice = this.voices[0].id; console.log(`API TTS: Selected first available voice ${this.voices[0].name}`); } // Update preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', 'voice', this.voiceOptions.voice); } return true; } /** * Preload speech for a text * @param {string} text - Text to preload * @returns {Promise} - Preloaded audio data */ async preloadSpeech(text) { if (!this.available || !text) { return null; } try { // Process text for TTS const processedText = this.preprocessText(text); console.log(`API TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`); // Make API request to generate speech const response = await fetch(this.apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: processedText, voice_id: this.voiceOptions.voice, model_id: this.voiceOptions.model, speed: this.voiceOptions.speed }) }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } // Get audio blob const audioBlob = await response.blob(); // Create audio element but don't play it const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); // Store preloaded data const preloadData = { audio, url: audioUrl, text: processedText }; this.preloadCache.set(text, preloadData); return preloadData; } catch (error) { console.warn("API TTS: Error preloading speech:", error); return null; } } /** * Speak text using preloaded audio * @param {Object} preloadData - Preloaded audio data * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ speakPreloaded(preloadData, callback = null) { if (!this.available || !preloadData || !preloadData.audio) { if (callback) { setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0); } return false; } try { // Stop any current speech this.stop(); const { audio, url, text } = preloadData; // Dispatch start event this.dispatchEvent('tts:speak:start', { text }); // Set up event listeners audio.addEventListener('ended', () => { this.currentAudio = null; // Clean up URL object URL.revokeObjectURL(url); // Dispatch end event this.dispatchEvent('tts:speak:end', { text }); if (callback) { callback({ success: true }); } }, { once: true }); audio.addEventListener('error', (error) => { this.currentAudio = null; // Clean up URL object URL.revokeObjectURL(url); // Dispatch error event this.dispatchEvent('tts:speak:error', { text, error: error.message || 'Unknown error' }); if (callback) { callback({ success: false, reason: 'playback_error', error }); } }, { once: true }); // Store reference to current audio this.currentAudio = audio; // Play the audio audio.play(); return true; } catch (error) { console.error("API TTS: Error playing preloaded speech:", error); // Dispatch error event this.dispatchEvent('tts:speak:error', { text: preloadData.text, error: error.message || 'Unknown error' }); if (callback) { setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0); } return false; } } /** * Speak text * @param {string} text - Text to speak * @param {Function} callback - Callback for when speech completes * @returns {boolean} - Success status */ async speak(text, callback = null) { if (!this.available) { if (callback) { setTimeout(() => callback({ success: false, reason: 'not_available' }), 0); } return false; } try { // Stop any current speech this.stop(); // Check if we have this in the preload cache if (this.preloadCache.has(text)) { const preloadData = this.preloadCache.get(text); this.preloadCache.delete(text); // Remove from cache return this.speakPreloaded(preloadData, callback); } // Process text for TTS const processedText = this.preprocessText(text); // Dispatch start event this.dispatchEvent('tts:speak:start', { text: processedText }); // Make API request to generate speech const response = await fetch(this.apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: processedText, voice_id: this.voiceOptions.voice, model_id: this.voiceOptions.model, speed: this.voiceOptions.speed }) }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } // Get audio blob const audioBlob = await response.blob(); // Create audio element const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); // Set up event listeners audio.addEventListener('ended', () => { this.currentAudio = null; // Clean up URL object URL.revokeObjectURL(audioUrl); // Dispatch end event this.dispatchEvent('tts:speak:end', { text: processedText }); if (callback) { callback({ success: true }); } }, { once: true }); audio.addEventListener('error', (error) => { this.currentAudio = null; // Clean up URL object URL.revokeObjectURL(audioUrl); // Dispatch error event this.dispatchEvent('tts:speak:error', { text: processedText, error: error.message || 'Unknown error' }); if (callback) { callback({ success: false, reason: 'playback_error', error }); } }, { once: true }); // Store reference to current audio this.currentAudio = audio; // Play the audio audio.play(); return true; } catch (error) { console.error("API TTS: Error generating speech:", error); // Dispatch error event this.dispatchEvent('tts:speak:error', { text, error: error.message || 'Unknown error' }); if (callback) { setTimeout(() => callback({ success: false, reason: 'generation_error', error }), 0); } return false; } } /** * Preprocess text for TTS * @param {string} text - Text to preprocess * @returns {string} - Processed text */ preprocessText(text) { if (!text) return ''; // Trim whitespace let processed = text.trim(); // Replace multiple spaces with a single space processed = processed.replace(/\s+/g, ' '); // Add a period at the end if there's no punctuation if (!/[.!?]$/.test(processed)) { processed += '.'; } return processed; } /** * Stop speaking */ stop() { if (this.currentAudio) { try { this.currentAudio.pause(); this.currentAudio = null; } catch (error) { console.error("API TTS: Error stopping speech:", error); } } } /** * Check if TTS is available * @returns {boolean} - True if TTS is available */ isAvailable() { return this.available; } /** * Get handler ID * @returns {string} - Handler ID */ getId() { return this.id; } /** * Get available voices * @returns {Promise} - Resolves with array of voice objects */ async getVoices() { if (!this.available) { return []; } try { const response = await fetch(`${this.apiEndpoint}/voices`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`); } const data = await response.json(); if (data && data.voices && Array.isArray(data.voices)) { return data.voices.map(voice => ({ id: voice.id, name: voice.name, language: voice.language || 'unknown' })); } return []; } catch (error) { console.error("API TTS: Error getting voices:", error); return []; } } /** * Set voice options * @param {Object} options - Voice options */ setVoiceOptions(options = {}) { if (options.voice) { this.voiceOptions.voice = options.voice; } if (options.model) { this.voiceOptions.model = options.model; } if (typeof options.speed === 'number') { // Clamp speed between 0.5 and 2.0 this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed)); } } }