/** * API TTS Handler Base Class * Base class for API-based TTS handlers */ import { TTSHandler } from './tts-handler.js'; import { moduleRegistry } from './module-registry.js'; export class ApiTTSHandlerBase extends TTSHandler { constructor(id, name) { super(); this.id = id; this.name = name; // Base voice options this.voiceOptions = { speed: 1.0 }; // State this.available = false; this.isReady = false; this.currentAudio = null; // Common API settings this.apiKey = ''; this.apiBaseUrl = ''; // Dependencies this.dependencies = ['localization', 'persistence-manager']; } /** * 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 ${this.name}`); } this.changeState('LOADING'); // Check for required dependencies const localization = this.getModule('localization'); const persistenceManager = this.getModule('persistence-manager'); if (!localization) { console.error(`${this.name}: Required dependency 'localization' not found`); this.changeState('ERROR'); return false; } if (!persistenceManager) { console.error(`${this.name}: Required dependency 'persistence-manager' not found`); this.changeState('ERROR'); return false; } if (progressCallback) { progressCallback(20, `${this.name} dependencies loaded`); } // Set up API key from preferences - should be empty by default this.apiKey = persistenceManager.getPreference('tts', `${this.id}_api_key`) || ''; if (progressCallback) { progressCallback(30, `${this.name} API key loaded`); } // Get default API URL const defaultApiUrl = this.getDefaultApiBaseUrl(); console.log(`${this.name}: Default API URL: ${defaultApiUrl}`); // 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) { console.log(`${this.name}: Saving default API URL to preferences: ${defaultApiUrl}`); persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl); } // Log the current values for debugging console.log(`${this.name} API KEY: ${this.apiKey ? '[SET]' : '[EMPTY]'}`); console.log(`${this.name} API URL: ${this.apiBaseUrl}`); if (progressCallback) { progressCallback(40, `${this.name} API URL set to: ${this.apiBaseUrl}`); } // Set up event listeners for API key and URL changes this.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged); this.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged); if (progressCallback) { progressCallback(50, `${this.name} event listeners registered`); } // Load available voices const voicesLoaded = await this.loadVoices(); if (progressCallback) { progressCallback(70, `${this.name} voices loaded`); } // Set up voice based on preferences await this.setupVoiceFromPreferences(); if (progressCallback) { progressCallback(90, `${this.name} voice preferences loaded`); } // Set availability based on API key presence this.available = true; this.isReady = true; if (progressCallback) { const statusMessage = this.apiKey ? `${this.name} initialized successfully` : `${this.name} initialized but unavailable (API key missing)`; progressCallback(100, statusMessage); } this.changeState(this.available ? 'FINISHED' : 'WAITING'); return true; } catch (error) { console.error(`${this.name}: Initialization error:`, error); if (progressCallback) { progressCallback(100, `${this.name} initialization failed - ${error.message}`); } this.changeState('ERROR'); return false; } } /** * Get a module from the registry * @param {string} moduleId - ID of the module to get * @returns {Object|null} - The module or null if not found */ getModule(moduleId) { return moduleRegistry.getModule(moduleId); } /** * Get the default API base URL for this provider * @returns {string} - Default API base URL */ getDefaultApiBaseUrl() { // Should 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 current locale const locale = localization.getLocale(); // Try to get voice preference for this specific provider const voiceId = persistenceManager.getPreference('tts', `${this.id}_voice`); if (voiceId) { // Set voice from preference this.voiceOptions.voice = voiceId; return true; } // If no specific voice preference, try to select a voice for the current locale return this.selectVoiceForLocale(locale); } /** * Load available voices from API * @returns {Promise} - Resolves with success status */ async loadVoices() { // Should be implemented by subclasses return false; } /** * Select a voice for the given locale * @param {string} locale - Locale code * @returns {boolean} - Success status */ selectVoiceForLocale(locale) { // Should be implemented by subclasses return this.selectDefaultVoice(); } /** * Select a default voice * @returns {boolean} - Success status */ selectDefaultVoice() { // Should be implemented by subclasses return false; } /** * Generate speech audio blob for the given text using the API. * Does not handle caching or playback, returns the Blob directly. * @param {string} text - The text to synthesize. * @returns {Promise} - A promise that resolves with the audio Blob, or null on failure. */ async generateSpeechAudio(text) { if (!this.apiKey) { console.error(`${this.name}: API key is not set.`); return null; } if (!this.isReady || !this.currentVoice) { console.error(`${this.name}: Handler not ready or no voice selected.`); return null; } const requestUrl = this.getApiRequestUrl(); const requestBody = this.getApiRequestBody(text); const requestHeaders = this.getApiRequestHeaders(); console.log(`${this.name}: Requesting speech generation...`); // Log sensitive info only if debug enabled (assuming a global DEBUG flag or similar) // if (DEBUG) { // console.debug(`${this.name}: URL: ${requestUrl}`); // console.debug(`${this.name}: Headers:`, JSON.stringify(requestHeaders)); // console.debug(`${this.name}: Body:`, JSON.stringify(requestBody)); // } try { const response = await fetch(requestUrl, { method: 'POST', headers: requestHeaders, body: JSON.stringify(requestBody) }); if (!response.ok) { let errorBody = 'Unknown error'; try { errorBody = await response.text(); // Try to get text first const errorJson = JSON.parse(errorBody); // Try to parse as JSON errorBody = errorJson.error?.message || errorJson.detail || JSON.stringify(errorJson); } catch (e) { // If parsing fails or it's not JSON, use the raw text console.warn(`${this.name}: Could not parse error response as JSON. Raw text: ${errorBody}`); } throw new Error(`API Error (${response.status} ${response.statusText}): ${errorBody}`); } // --- Response Handling (Specific to API - Override if necessary) --- // Default assumes response IS the audio blob const audioBlob = await response.blob(); console.log(`${this.name}: Received audio blob, size: ${audioBlob.size}`); // ------------------------------------------------------------------- if (!audioBlob || audioBlob.size === 0) { throw new Error('Received empty audio blob from API.'); } // Return the audio data blob return audioBlob; } catch (error) { console.error(`${this.name}: Error generating speech audio:`, error); this.handleApiError(error); return null; } } /** * Plays preloaded audio data. * @param {Blob} audioData - The audio data Blob to play. * @param {Function} [callback=null] - Optional callback function. */ speakPreloaded(audioData, callback = null) { // This method might now be redundant if the factory handles all playback. // However, keeping it in case direct playback of preloaded data is needed elsewhere. // Or, it could be simplified to just return the blob if factory always handles play. // For now, let's keep the playback logic but it might be unused by the factory flow. console.log(`${this.name}: Playing preloaded audio...`); const audioManager = this.getModule('audio-manager'); if (audioManager && audioData) { // This assumes audioManager.play handles Blobs audioManager.play(audioData, callback); } else { console.error(`${this.name}: AudioManager not found or no audio data to play.`); if (callback) callback(false, "Playback error"); } } /** * Stops the currently playing audio. */ stop() { console.log(`${this.name}: Stop requested.`); const audioManager = this.getModule('audio-manager'); if (audioManager) { audioManager.stop(); } // Reset any internal state if needed this.currentAudio = null; } /** * Speak the given text using the API. * This method now primarily calls generateSpeechAudio and returns the result. * Caching and playback are handled by TTSFactoryModule. * @param {string} text - The text to speak. * @returns {Promise} - A promise resolving to the audio Blob or null on failure. */ async speak(text) { console.log(`${this.name}: speak called for text: ${text.substring(0, 30)}...`); try { // Generate audio data const audioData = await this.generateSpeechAudio(text); if (!audioData) { console.error(`${this.name}: Failed to generate audio for speak.`); return null; } // Return the Blob for the factory to handle return audioData; } catch (error) { console.error(`${this.name}: Error in speak method:`, error); return null; } } /** * Preloads speech for the given text. * Generates the audio data but does not play it. * Returns the generated Blob for the factory to cache. * @param {string} text - The text to preload. * @returns {Promise} - A promise resolving to the audio Blob or null on failure. */ async preloadSpeech(text) { console.log(`${this.name}: preloadSpeech called for text: ${text.substring(0, 30)}...`); try { // Generate audio data using the main generation method const audioData = await this.generateSpeechAudio(text); if (audioData) { console.log(`${this.name}: Successfully preloaded speech (blob generated).`); return audioData; // Return the Blob for the factory } else { console.error(`${this.name}: Failed to generate audio for preload.`); return null; } } catch (error) { console.error(`${this.name}: Error during preloadSpeech:`, error); return null; } } /** * 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; } /** * 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() { // Should be implemented by subclasses return []; } /** * Set voice options * @param {Object} options - Voice options */ setVoiceOptions(options = {}) { if (options.voice) { this.voiceOptions.voice = options.voice; // Save the voice preference const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager) { persistenceManager.updatePreference('tts', `${this.id}_voice`, options.voice); } } 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)); } // Additional provider-specific options should be handled by subclasses } /** * 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; // Don't update API key } // 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 functionality status but don't make it unavailable // We want it to stay in the dropdown for configuration const wasFullyFunctional = this.available; const isFullyFunctional = !!this.apiKey; // Only update internal state - don't change availability for UI purposes if (isFullyFunctional) { this.changeState('FINISHED'); } else { // Not WAITING - we want it to stay in dropdown this.changeState('CONFIGURING'); } // Log the key change but don't affect availability for UI console.log(`${this.name}: API key ${newKey ? 'set' : 'cleared'}. Fully functional: ${isFullyFunctional}`); // Always stay available in the UI dropdown this.available = true; } } /** * 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); } // Log the URL change but don't affect availability console.log(`${this.name}: API URL updated to ${newUrl}`); // Always stay available in the UI dropdown this.available = true; } } }