Added support for openai api tts.
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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<boolean>} - 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);
|
||||
}
|
||||
|
||||
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.available ?
|
||||
`${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<boolean>} - 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<boolean>} - 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for a text
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded audio data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
// Don't try to preload if handler isn't ready, available, or if no text or API key
|
||||
if (!this.isReady || !this.available || !text || !this.apiKey) {
|
||||
if (!this.apiKey) {
|
||||
console.log(`${this.name}: Skipping preload speech - no API key set`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Generate speech audio data
|
||||
const audioData = await this.generateSpeechAudio(processedText);
|
||||
|
||||
if (!audioData) {
|
||||
console.error(`${this.name}: Failed to generate audio data for preloading`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store in centralized TTSFactory cache
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory) {
|
||||
ttsFactory.cacheSpeech(text, audioData);
|
||||
}
|
||||
|
||||
// Return audio data
|
||||
return audioData;
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Preload speech error:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio data
|
||||
* @param {string} text - Text to generate speech for
|
||||
* @returns {Promise<Object>} - Audio data (Blob)
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
// Should be implemented by subclasses
|
||||
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.isReady || !this.available || !preloadData) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop any current audio
|
||||
this.stop();
|
||||
|
||||
// Create Blob URL
|
||||
const audioUrl = URL.createObjectURL(preloadData);
|
||||
|
||||
// Create new audio element
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Set up event handlers
|
||||
audio.addEventListener('ended', () => {
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
// Clear current audio reference
|
||||
if (this.currentAudio === audio) {
|
||||
this.currentAudio = null;
|
||||
}
|
||||
|
||||
// Dispatch completion event
|
||||
this.dispatchEvent('tts:speak:complete', {});
|
||||
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
audio.addEventListener('error', (error) => {
|
||||
console.error(`${this.name}: Playback error:`, error);
|
||||
|
||||
// Clean up URL object
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', { 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(`${this.name}: Error playing preloaded audio:`, error);
|
||||
|
||||
// Dispatch error event
|
||||
this.dispatchEvent('tts:speak:error', {
|
||||
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.isReady || !this.available || !text) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Process text for TTS
|
||||
const processedText = this.preprocessText(text);
|
||||
|
||||
// Check if already preloaded
|
||||
const ttsFactory = this.getModule('tts-factory');
|
||||
if (ttsFactory && ttsFactory.isSpeechCached(text)) {
|
||||
return this.speakPreloaded(ttsFactory.getCachedSpeech(text), callback);
|
||||
}
|
||||
|
||||
// Generate audio data
|
||||
const audioData = await this.generateSpeechAudio(processedText);
|
||||
|
||||
if (!audioData) {
|
||||
if (callback) {
|
||||
setTimeout(() => callback({ success: false, reason: 'generation_failed' }), 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store in centralized TTSFactory cache
|
||||
if (ttsFactory) {
|
||||
ttsFactory.cacheSpeech(text, audioData);
|
||||
}
|
||||
|
||||
// Play the audio
|
||||
return this.speakPreloaded(audioData, callback);
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: 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(`${this.name}: 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<Array>} - 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 || '';
|
||||
|
||||
// Update API key
|
||||
this.apiKey = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user