708 lines
24 KiB
JavaScript
708 lines
24 KiB
JavaScript
/**
|
|
* 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();
|
|
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 {
|
|
if (progressCallback) {
|
|
progressCallback(10, "Initializing API TTS Handler");
|
|
}
|
|
|
|
// 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;
|
|
|
|
if (progressCallback) {
|
|
progressCallback(100, "API TTS initialization failed");
|
|
}
|
|
|
|
// Return true to indicate the module initialized successfully
|
|
// even though there was an error
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
}
|
|
}
|