577 lines
20 KiB
JavaScript
577 lines
20 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', 'game-config'];
|
|
|
|
// 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',
|
|
'notifyReadyState'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
this.addEventListener(document, 'preference-updated', (event) => {
|
|
const { category, key } = event.detail || {};
|
|
if (category !== 'audio') {
|
|
return;
|
|
}
|
|
|
|
if (['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled', '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<boolean>} - Resolves with success status
|
|
*/
|
|
async setupVoiceFromPreferences() {
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
const gameConfig = this.getModule('game-config');
|
|
|
|
if (!persistenceManager) {
|
|
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 = gameConfig?.getLocale?.() || 'en_US';
|
|
|
|
// 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, options = {}) {
|
|
// 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<Object>} - Resolves when audio finishes playing
|
|
*/
|
|
async speakPreloaded(preloadData, callback = null) {
|
|
const completionCallback = typeof callback === 'function' ? callback : null;
|
|
if (!preloadData || !preloadData.audioData) {
|
|
console.error(`${this.name}: Invalid preloaded data`);
|
|
const result = { success: false, reason: 'invalid_data' };
|
|
if (completionCallback) completionCallback(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 (completionCallback) completionCallback(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 masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
|
|
const ttsVolume = persistenceManager.getPreference(
|
|
'audio',
|
|
'ttsVolume',
|
|
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
|
|
);
|
|
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
|
|
|
|
return Math.max(0, Math.min(1, (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
|
|
}
|
|
|
|
/**
|
|
* 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<number>} - 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<Object>} - Preloaded speech data
|
|
*/
|
|
async preloadSpeech(text, options = {}) {
|
|
if (!this.isReady) {
|
|
return { success: false, reason: 'not_ready' };
|
|
}
|
|
|
|
try {
|
|
// Generate speech
|
|
const result = await this.generateSpeechAudio(text, options);
|
|
|
|
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`);
|
|
|
|
// If we have a key now (and didn't before), try initializing voices.
|
|
// TTS providers must not depend back on tts-factory; they publish
|
|
// readiness and the factory listens for handler-state changes.
|
|
if (this.isReady && !wasReady) {
|
|
this.loadVoices().then((voicesLoaded) => {
|
|
this.isReady = voicesLoaded !== false && !!this.apiKey;
|
|
this.setupVoiceFromPreferences().then(() => {
|
|
console.log(`${this.name}: API key status: ${this.isReady ? 'ready' : 'not ready'}`);
|
|
this.notifyReadyState();
|
|
});
|
|
});
|
|
} else {
|
|
this.notifyReadyState();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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'}`);
|
|
|
|
this.notifyReadyState();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
notifyReadyState() {
|
|
document.dispatchEvent(new CustomEvent('tts:handler-state-changed', {
|
|
detail: { handler: this.id, ready: this.isReady === true }
|
|
}));
|
|
document.dispatchEvent(new CustomEvent('tts:status:updated', {
|
|
detail: { provider: this.id, ready: this.isReady === true }
|
|
}));
|
|
}
|
|
}
|