/** * 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); // Declare proper dependencies according to architecture principles this.dependencies = ['persistence-manager', 'localization']; // Basic voice options this.voiceOptions = { speed: 1.0, voice: null }; // API settings this.apiKey = ''; this.apiBaseUrl = ''; // State this.currentAudio = null; this.currentPlaybackFinish = null; // Bind additional methods this.bindMethods([ 'handleApiKeyChanged', 'handleApiUrlChanged', 'speakPreloaded', 'loadVoices', 'selectVoiceForLocale', 'selectDefaultVoice', 'generateSpeechAudio', 'preprocessText', 'getPlaybackVolume', 'applyCurrentVolume' ]); } /** * 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); this.addEventListener(document, 'preference-updated', (event) => { const { category, key } = event.detail || {}; if (category !== 'audio') { return; } if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) { this.applyCurrentVolume(); } }); // 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; if (!this.isReady) { console.info(`${this.name}: API key not configured; provider unavailable until configured`); this.reportProgress(100, `${this.name} not configured`); return true; } // Only mark as complete if we have an 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) { console.error(`${this.name}: Required dependencies not found`); 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 ID, use it if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) { this.voiceOptions.voice = this.voices.find(v => v.id === preferredVoiceId); return true; } // Otherwise, select 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.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 {Promise} - Resolves when audio finishes playing */ async speakPreloaded(preloadData, callback = null) { if (!preloadData || !preloadData.audioData) { console.error(`${this.name}: Invalid preloaded data`); const result = { success: false, reason: 'invalid_data' }; if (callback) callback(result); return result; } return new Promise((resolve) => { // Create an audio element to play the audio const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); let settled = false; audio.volume = this.getPlaybackVolume(); console.log(`${this.name}: Playback volume set to ${audio.volume.toFixed(2)}`); // Set up state this.isSpeaking = true; this.currentAudio = audio; const finish = (result) => { if (settled) { return; } settled = true; this.isSpeaking = false; if (this.currentAudio === audio) { this.currentAudio = null; } if (this.currentPlaybackFinish === finish) { this.currentPlaybackFinish = null; } URL.revokeObjectURL(audioUrl); if (callback) callback(result); resolve(result); }; this.currentPlaybackFinish = finish; // Set up event handlers audio.onended = () => { finish({ success: true }); }; audio.onerror = (error) => { console.error(`${this.name}: Audio playback error:`, error); finish({ success: false, reason: 'playback_error', error }); }; // Play the audio audio.play().then(() => { document.dispatchEvent(new CustomEvent('tts:audio-started', { detail: { provider: this.id || this.name } })); }).catch(error => { console.error(`${this.name}: Failed to play audio:`, error); finish({ success: false, reason: 'playback_error', error }); }); }); } /** * Get the current effective TTS playback volume. * @returns {number} Volume from 0 to 1. */ getPlaybackVolume() { const persistenceManager = this.getModule('persistence-manager'); if (!persistenceManager) { return 1.0; } const masterVolume = persistenceManager.getPreference( 'audio', 'masterVolume', persistenceManager.getPreference('audio', 'master_volume', 1.0) ); const ttsVolume = persistenceManager.getPreference( 'audio', 'ttsVolume', persistenceManager.getPreference('audio', 'tts_volume', 1.0) ); return Math.max(0, Math.min(1, masterVolume * ttsVolume)); } /** * Apply updated volume settings to currently playing audio. */ applyCurrentVolume() { if (!this.currentAudio) { return; } this.currentAudio.volume = this.getPlaybackVolume(); console.log(`${this.name}: Updated current playback volume to ${this.currentAudio.volume.toFixed(2)}`); } /** * Stop speaking * @returns {boolean} - Success status */ stop() { if (this.currentAudio) { try { // Stop current audio this.currentAudio.pause(); this.currentAudio.currentTime = 0; if (this.currentPlaybackFinish) { this.currentPlaybackFinish({ success: false, reason: 'stopped' }); } // Clean up this.isSpeaking = false; this.currentAudio = null; return true; } catch (error) { console.error(`${this.name}: Error stopping audio:`, error); return false; } } return true; // Already stopped } fadeOutCurrentAudio(duration = 1000) { if (!this.currentAudio) { return Promise.resolve(true); } const audio = this.currentAudio; const startVolume = audio.volume; const startedAt = performance.now(); return new Promise((resolve) => { const tick = () => { const progress = Math.min(1, (performance.now() - startedAt) / duration); audio.volume = startVolume * (1 - progress); if (progress >= 1) { this.stop(); resolve(true); return; } requestAnimationFrame(tick); }; tick(); }); } /** * 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; } /** * Calculate audio duration from audio buffer * @param {ArrayBuffer} audioData - Audio data buffer * @returns {Promise} - Duration in milliseconds */ async calculateAudioDuration(audioData) { try { // Use Web Audio API to decode audio and get duration const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioBuffer = await audioContext.decodeAudioData(audioData.slice(0)); const durationMs = audioBuffer.duration * 1000; // Close the audio context to free resources await audioContext.close(); console.log(`${this.name}: Calculated audio duration: ${durationMs.toFixed(0)}ms`); return durationMs; } catch (error) { console.warn(`${this.name}: Failed to calculate audio duration:`, error); return 0; } } /** * 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' }; } // Calculate actual audio duration if not provided let duration = result.duration || 0; if (duration === 0 && result.audioData) { duration = await this.calculateAudioDuration(result.audioData); } return { success: true, audioData: result.audioData, text, duration: duration }; } 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 || ''; const oldKey = this.apiKey; // 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 && oldKey !== newKey) { persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey); } // Update ready state const wasReady = this.isReady; this.isReady = !!this.apiKey; // If state changed (now ready/not-ready), notify the TTS factory if (wasReady !== this.isReady) { console.log(`${this.name}: TTS ready state changed to ${this.isReady ? 'ready' : 'not ready'} after API key change`); // Find and notify the TTS factory const ttsFactory = this.getModule('tts-factory'); if (ttsFactory) { // If we have a key now (and didn't before), try initializing voices if (this.isReady && !wasReady) { // Reload voices with the new API key this.loadVoices().then((voicesLoaded) => { this.isReady = voicesLoaded !== false && !!this.apiKey; // Then set up voice from preferences this.setupVoiceFromPreferences().then(() => { console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`); // Notify the factory of our readiness change ttsFactory.updateTTSAvailability(); document.dispatchEvent(new CustomEvent('tts:status:updated', { detail: { provider: this.id, ready: this.isReady } })); }); }); } else { // Just update the availability ttsFactory.updateTTSAvailability(); } } } } } /** * Handle API URL change event * @param {Event} event - Event object */ handleApiUrlChanged(event) { if (event && event.detail && event.detail.provider === this.id) { const oldUrl = this.apiBaseUrl; const newUrl = event.detail.url || this.getDefaultApiBaseUrl(); // Update API URL this.apiBaseUrl = newUrl; // Save to preferences const persistenceManager = this.getModule('persistence-manager'); if (persistenceManager && oldUrl !== newUrl) { persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl); } // Only reinitialize if the URL actually changed and we have an API key if (oldUrl !== newUrl && this.isReady) { console.log(`${this.name}: API URL changed, reinitializing`); // Reload voices with the new API URL if we're ready this.loadVoices().then((voicesLoaded) => { this.isReady = voicesLoaded !== false && !!this.apiKey; // Then set up voice from preferences this.setupVoiceFromPreferences().then(() => { console.log(`${this.name}: API URL status: ${this.isReady ? 'ready' : 'not ready'}`); // Notify the TTS factory const ttsFactory = this.getModule('tts-factory'); if (ttsFactory) { ttsFactory.updateTTSAvailability(); } document.dispatchEvent(new CustomEvent('tts:status:updated', { detail: { provider: this.id, ready: this.isReady } })); }); }); } } } }