/** * 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); } } } }