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:
+689
-262
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user