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

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));
}
}
}