507 lines
18 KiB
JavaScript
507 lines
18 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
|
|
// 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<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;
|
|
}
|
|
|
|
/**
|
|
* 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<Blob|null>} - 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<Blob|null>} - 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<Blob|null>} - 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<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 || '';
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|