Fix TTS module initialization and dependency issues. Update module IDs for consistency, improve circular dependency detection, and fix UI Controller event handling.

This commit is contained in:
2025-04-04 19:15:28 +00:00
parent 02c7b9ef28
commit 49a5af252c
33 changed files with 7227 additions and 4060 deletions
+689 -262
View File
@@ -1,280 +1,707 @@
/**
* ApiTTSHandler for AI Interactive Fiction
* Implementation using external TTS APIs like ElevenLabs
* API TTS Handler
* Provides TTS via external APIs (e.g., ElevenLabs)
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class ApiTTSHandler extends TTSHandler {
constructor() {
super(); // Initialize the base TTSHandler
this.isReady = false;
this.enabled = false; // Disabled by default until options panel is implemented
this.audioElement = null;
// Set voice options through base class
this.voiceOptions = {
voice: '8JNqTOY3RaSYcHTVJZ0G', // Default ElevenLabs voice ID
model: 'eleven_multilingual_v1',
stability: 0,
similarityBoost: 0,
style: 0.5,
useSpeakerBoost: true
};
this.apiKey = 'd191e27c2e5b07573b39fe70f0783f48'; // From speech.js
this.apiUrl = 'https://api.elevenlabs.io/v1/text-to-speech';
this.voicesApiUrl = 'https://api.elevenlabs.io/v1/voices'; // Separate URL for voices endpoint
this.cache = new Map();
this.currentCallback = null;
}
/**
* Get the ID of this provider
* @returns {string} - Provider ID
*/
getId() {
return 'api';
}
/**
* Initialize the API TTS system
* @param {Function} progressCallback - Optional callback for progress updates
* @returns {Promise<boolean>} - Resolves to true if initialization was successful
*/
async initialize(progressCallback = null) {
try {
if (progressCallback) progressCallback(20, 'Setting up API TTS');
// Create audio element for playback
this.audioElement = new Audio();
// Set up audio event listeners
this.audioElement.onended = () => {
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
};
this.audioElement.onerror = (error) => {
console.error('Audio playback error:', error);
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
};
if (progressCallback) progressCallback(80, 'API TTS ready');
// Only check API if enabled
if (this.enabled) {
// Check if the API is reachable with a simple request
constructor() {
super();
this.id = 'api';
this.name = 'API TTS Handler';
// Voice options
this.voiceOptions = {
voice: 'pNInz6obpgDQGcFmaJgB', // Default German voice ID for ElevenLabs
model: 'eleven_multilingual_v2', // Use the multilingual model for better German
speed: 1.0
};
// State
this.available = false;
this.isReady = false;
this.currentAudio = null;
this.preloadCache = new Map();
// API endpoint
this.apiEndpoint = '/api/tts';
// Dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'isAvailable',
'getId',
'getVoices',
'setVoiceOptions',
'getModule',
'setupVoiceFromPreferences',
'selectVoiceForLocale',
'selectDefaultVoice'
]);
}
/**
* 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);
}
/**
* Initialize the API TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
try {
const testResponse = await fetch(this.voicesApiUrl, {
method: 'GET',
headers: {
'xi-api-key': this.apiKey
if (progressCallback) {
progressCallback(10, "Initializing API TTS Handler");
}
});
if (testResponse.ok) {
// Check for required dependencies
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
if (!localization) {
console.error("API TTS: Localization module not found, required dependency missing");
if (progressCallback) {
progressCallback(100, "API TTS initialization failed - missing localization");
}
return false;
}
if (!persistenceManager) {
console.error("API TTS: Persistence Manager module not found, required dependency missing");
if (progressCallback) {
progressCallback(100, "API TTS initialization failed - missing persistence manager");
}
return false;
}
// Create audio element
this.audioElement = new Audio();
if (progressCallback) {
progressCallback(30, "Loading voices");
}
// Load available voices
try {
await this.loadVoices();
} catch (error) {
console.warn("API TTS: Failed to load voices, continuing with initialization", error);
// Continue initialization even if voice loading fails
}
if (progressCallback) {
progressCallback(50, "Setting up voice preferences");
}
// Set up voice based on preferences and locale
try {
const voiceSetupSuccess = await this.setupVoiceFromPreferences();
if (!voiceSetupSuccess) {
console.warn("API TTS: Could not set up voice from preferences, using default");
}
} catch (error) {
console.warn("API TTS: Error setting up voice preferences", error);
// Continue initialization even if voice setup fails
}
// Check if API is available by making a test request
try {
if (progressCallback) {
progressCallback(70, "Checking API availability");
}
const response = await fetch(`${this.apiEndpoint}/voices`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.warn(`API TTS: API endpoint not available (${response.status} ${response.statusText}). Will use fallback.`);
this.available = false;
this.isReady = true; // Still mark as ready, just not available
if (progressCallback) {
progressCallback(100, "API TTS unavailable, using fallback");
}
// Return true to indicate the module initialized successfully
// even though the API is not available
return true;
}
const data = await response.json();
if (progressCallback) {
progressCallback(90, "API TTS available");
}
// Check for German voices and set default if available
if (data && data.voices && Array.isArray(data.voices)) {
const germanVoices = data.voices.filter(voice =>
voice.name.toLowerCase().includes('german') ||
voice.language === 'de' ||
voice.language === 'de-DE'
);
if (germanVoices.length > 0) {
// Use the first German voice as default
this.voiceOptions.voice = germanVoices[0].id;
console.log(`API TTS: Found German voice: ${germanVoices[0].name} (${germanVoices[0].id})`);
}
}
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "API TTS Handler ready");
}
return true;
} catch (error) {
console.warn("API TTS: Error checking API availability:", error);
// Mark as ready but not available
this.available = false;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "API TTS unavailable due to error");
}
// Return true to indicate the module initialized successfully
// even though the API is not available
return true;
}
} catch (error) {
console.error("Error initializing API TTS Handler:", error);
// Mark as ready but not available
this.available = false;
this.isReady = true;
console.log('API TTS initialized successfully');
} else {
console.warn('API TTS initialized but API may not be accessible');
}
} catch (apiError) {
console.warn('Could not verify API access, but continuing:', apiError);
// We'll still mark as ready and try when speak is called
this.isReady = true;
}
} else {
console.log('API TTS is disabled by default. Enable via options panel when implemented.');
}
if (progressCallback) progressCallback(100, 'API TTS initialization complete');
return this.isReady;
} catch (error) {
console.error('Error initializing API TTS:', error);
return false;
}
}
/**
* Check if API TTS is available
* @returns {boolean} - True if API TTS is ready to use
*/
isAvailable() {
return this.isReady && this.enabled;
}
/**
* Generate an MD5 hash for text caching
* @param {string} text - Text to hash
* @returns {string} - MD5 hash
*/
generateHash(text) {
// Simple hash function for client-side use
// For production, consider using a proper hashing library
let hash = 0;
if (text.length === 0) return hash.toString();
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16);
}
/**
* Convert text to speech via API and play it
* @param {string} text - Text to speak
* @param {Function} callback - Called when speech completes
*/
async speak(text, callback = null) {
if (!this.isAvailable() || !text) {
if (callback) callback();
return;
}
// Stop any current speech
this.stop();
// Set new callback
this.currentCallback = callback;
try {
// Check cache first
const cacheKey = this.generateHash(text + JSON.stringify(this.voiceOptions));
let audioUrl = this.cache.get(cacheKey);
if (!audioUrl) {
// Make API request to get audio
const response = await fetch(`${this.apiUrl}/${this.voiceOptions.voice}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'xi-api-key': this.apiKey
},
body: JSON.stringify({
text: text,
model_id: this.voiceOptions.model,
voice_settings: {
stability: this.voiceOptions.stability,
similarity_boost: this.voiceOptions.similarityBoost,
style: this.voiceOptions.style,
use_speaker_boost: this.voiceOptions.useSpeakerBoost
if (progressCallback) {
progressCallback(100, "API TTS initialization failed");
}
})
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
// Return true to indicate the module initialized successfully
// even though there was an error
return true;
}
// Get the audio data as blob
const audioBlob = await response.blob();
audioUrl = URL.createObjectURL(audioBlob);
// Store in cache
this.cache.set(cacheKey, audioUrl);
}
// Play the audio
this.audioElement.src = audioUrl;
await this.audioElement.play();
} catch (error) {
console.error('Error speaking with API TTS:', error);
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
}
}
/**
* Stop any ongoing speech
*/
stop() {
if (this.audioElement) {
this.audioElement.pause();
this.audioElement.currentTime = 0;
}
if (this.currentCallback) {
const callback = this.currentCallback;
this.currentCallback = null;
callback();
}
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice !== undefined) this.voiceOptions.voice = options.voice;
if (options.model !== undefined) this.voiceOptions.model = options.model;
if (options.stability !== undefined) this.voiceOptions.stability = options.stability;
if (options.similarityBoost !== undefined) this.voiceOptions.similarityBoost = options.similarityBoost;
if (options.style !== undefined) this.voiceOptions.style = options.style;
if (options.useSpeakerBoost !== undefined) this.voiceOptions.useSpeakerBoost = options.useSpeakerBoost;
}
/**
* Get available voices from the API
* @returns {Promise<Array>} - Array of available voices
*/
async getVoices() {
if (!this.enabled) {
return [];
/**
* Set up voice based on preferences and locale
* @returns {Promise<boolean>} - Resolves with success status
*/
async setupVoiceFromPreferences() {
try {
// Get localization and persistence manager modules
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
// Both modules should be available as we checked in initialize
if (!localization || !persistenceManager) {
console.error("API TTS: Required modules not available for voice setup");
return this.selectDefaultVoice();
}
// Get current locale and preferred voice
const currentLocale = localization.getLocale();
const preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
// If we have a preferred voice, use it
if (preferredVoice) {
this.voiceOptions.voice = preferredVoice;
console.log(`API TTS: Using preferred voice: ${preferredVoice}`);
return true;
}
// Otherwise select based on locale
console.log(`API TTS: No preferred voice, selecting for locale: ${currentLocale}`);
return this.selectVoiceForLocale(currentLocale);
} catch (error) {
console.error("API TTS: Error setting up voice from preferences:", error);
return this.selectDefaultVoice();
}
}
try {
const response = await fetch(this.voicesApiUrl, {
method: 'GET',
headers: {
'xi-api-key': this.apiKey
/**
* Load available voices from API
* @returns {Promise<boolean>} - Resolves with success status
*/
async loadVoices() {
try {
// Fetch available voices from API
const response = await fetch(`${this.apiEndpoint}/voices`);
if (!response.ok) {
console.warn(`API TTS: Failed to load voices - ${response.status} ${response.statusText}`);
return false;
}
const data = await response.json();
if (!data.voices || !Array.isArray(data.voices)) {
console.warn("API TTS: Invalid voice data received");
return false;
}
this.voices = data.voices;
console.log(`API TTS: Loaded ${this.voices.length} voices`);
return true;
} catch (error) {
console.error("Error loading API TTS voices:", error);
return false;
}
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.voices || [];
} catch (error) {
console.error('Error getting voices from API:', error);
return [];
}
}
/**
* Enable or disable the API TTS
* @param {boolean} enabled - Whether the API TTS should be enabled
*/
setEnabled(enabled) {
this.enabled = enabled;
if (enabled && !this.isReady) {
// Re-initialize if enabled
this.initialize();
/**
* Select a voice for the given locale
* @param {string} locale - Locale code
* @returns {boolean} - Success status
*/
selectVoiceForLocale(locale) {
if (!locale || this.voices.length === 0) {
return this.selectDefaultVoice();
}
// Normalize locale
const normalizedLocale = locale.toLowerCase();
// Try to find a voice for the exact locale
let matchingVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase() === normalizedLocale
);
// If no exact match, try to find a voice for the language part
if (!matchingVoice) {
const langPart = normalizedLocale.split('-')[0];
matchingVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase().startsWith(langPart)
);
}
// If still no match, use default
if (!matchingVoice) {
return this.selectDefaultVoice();
}
// Set the matching voice
this.voiceOptions.voice = matchingVoice.id;
console.log(`API TTS: Selected voice ${matchingVoice.name} for locale ${locale}`);
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', matchingVoice.id || matchingVoice.name);
}
return true;
}
/**
* Select a default voice
* @returns {boolean} - Success status
*/
selectDefaultVoice() {
if (this.voices.length === 0) {
console.warn("API TTS: No voices available for default selection");
return false;
}
// Prefer English voices if available
const englishVoice = this.voices.find(voice =>
voice.lang && voice.lang.toLowerCase().startsWith('en')
);
if (englishVoice) {
this.voiceOptions.voice = englishVoice.id;
console.log(`API TTS: Selected default English voice ${englishVoice.name}`);
} else {
// Otherwise use the first available voice
this.voiceOptions.voice = this.voices[0].id;
console.log(`API TTS: Selected first available voice ${this.voices[0].name}`);
}
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', this.voiceOptions.voice);
}
return true;
}
/**
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded audio data
*/
async preloadSpeech(text) {
if (!this.available || !text) {
return null;
}
try {
// Process text for TTS
const processedText = this.preprocessText(text);
console.log(`API TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
// Make API request to generate speech
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: processedText,
voice_id: this.voiceOptions.voice,
model_id: this.voiceOptions.model,
speed: this.voiceOptions.speed
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Get audio blob
const audioBlob = await response.blob();
// Create audio element but don't play it
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Store preloaded data
const preloadData = {
audio,
url: audioUrl,
text: processedText
};
this.preloadCache.set(text, preloadData);
return preloadData;
} catch (error) {
console.warn("API TTS: Error preloading speech:", error);
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.available || !preloadData || !preloadData.audio) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
const { audio, url, text } = preloadData;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text });
// Set up event listeners
audio.addEventListener('ended', () => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(url);
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text });
if (callback) {
callback({ success: true });
}
}, { once: true });
audio.addEventListener('error', (error) => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(url);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
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("API TTS: Error playing preloaded speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: preloadData.text,
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.available) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
// Check if we have this in the preload cache
if (this.preloadCache.has(text)) {
const preloadData = this.preloadCache.get(text);
this.preloadCache.delete(text); // Remove from cache
return this.speakPreloaded(preloadData, callback);
}
// Process text for TTS
const processedText = this.preprocessText(text);
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text: processedText });
// Make API request to generate speech
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: processedText,
voice_id: this.voiceOptions.voice,
model_id: this.voiceOptions.model,
speed: this.voiceOptions.speed
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
// Get audio blob
const audioBlob = await response.blob();
// Create audio element
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
// Set up event listeners
audio.addEventListener('ended', () => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(audioUrl);
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text: processedText });
if (callback) {
callback({ success: true });
}
}, { once: true });
audio.addEventListener('error', (error) => {
this.currentAudio = null;
// Clean up URL object
URL.revokeObjectURL(audioUrl);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: processedText,
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("API TTS: 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("API TTS: 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() {
if (!this.available) {
return [];
}
try {
const response = await fetch(`${this.apiEndpoint}/voices`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data && data.voices && Array.isArray(data.voices)) {
return data.voices.map(voice => ({
id: voice.id,
name: voice.name,
language: voice.language || 'unknown'
}));
}
return [];
} catch (error) {
console.error("API TTS: Error getting voices:", error);
return [];
}
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice) {
this.voiceOptions.voice = options.voice;
}
if (options.model) {
this.voiceOptions.model = options.model;
}
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));
}
}
}
/**
* Check if speech is currently playing
* @returns {boolean} - True if speaking
*/
isSpeaking() {
return this.audioElement !== null &&
!this.audioElement.paused &&
!this.audioElement.ended;
}
}