Files
ai.interactive.fiction/public/js/api-tts-module-base.js
T

443 lines
15 KiB
JavaScript

/**
* 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;
// Bind additional methods
this.bindMethods([
'handleApiKeyChanged',
'handleApiUrlChanged',
'speakPreloaded',
'loadVoices',
'selectVoiceForLocale',
'selectDefaultVoice',
'generateSpeechAudio',
'preprocessText'
]);
}
/**
* Initialize the API TTS module
* @returns {Promise<boolean>} - 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;
if (!this.isReady) {
console.error(`${this.name}: Missing API key, initialization failed`);
this.reportProgress(100, `${this.name} initialization failed - missing API key`);
return false; // Properly report failure when API key is missing
}
// 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<boolean>} - 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<boolean>} - 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<Object>} - 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) {
console.error(`${this.name}: Invalid preloaded data`);
if (callback) callback({ success: false, reason: 'invalid_data' });
return false;
}
// 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);
// Set up state
this.isSpeaking = true;
this.currentAudio = audio;
// Set up event handlers
audio.onended = () => {
this.isSpeaking = false;
this.currentAudio = null;
URL.revokeObjectURL(audioUrl);
if (callback) callback({ success: true });
};
audio.onerror = (error) => {
console.error(`${this.name}: Audio playback error:`, error);
this.isSpeaking = false;
this.currentAudio = null;
URL.revokeObjectURL(audioUrl);
if (callback) callback({ success: false, reason: 'playback_error', error });
};
// Play the audio
audio.play().catch(error => {
console.error(`${this.name}: Failed to play audio:`, error);
if (callback) callback({ success: false, reason: 'playback_error', error });
});
return true;
}
/**
* Stop speaking
* @returns {boolean} - Success status
*/
stop() {
if (this.currentAudio) {
try {
// Stop current audio
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
// 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
}
/**
* 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<Object>} - 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 || '';
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) {
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(() => {
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully initialized with new API key`);
// Notify the factory of our readiness change
ttsFactory.updateTTSAvailability();
});
});
} 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) {
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(() => {
// Then set up voice from preferences
this.setupVoiceFromPreferences().then(() => {
console.log(`${this.name}: Successfully reinitialized with new API URL`);
// Notify the TTS factory
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.updateTTSAvailability();
}
});
});
}
}
}
}