281 lines
8.0 KiB
JavaScript
281 lines
8.0 KiB
JavaScript
/**
|
|
* ApiTTSHandler for AI Interactive Fiction
|
|
* Implementation using external TTS APIs like ElevenLabs
|
|
*/
|
|
import { TTSHandler } from './tts-handler.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
|
|
try {
|
|
const testResponse = await fetch(this.voicesApiUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'xi-api-key': this.apiKey
|
|
}
|
|
});
|
|
|
|
if (testResponse.ok) {
|
|
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 (!response.ok) {
|
|
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
// 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 [];
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.voicesApiUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'xi-api-key': this.apiKey
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if speech is currently playing
|
|
* @returns {boolean} - True if speaking
|
|
*/
|
|
isSpeaking() {
|
|
return this.audioElement !== null &&
|
|
!this.audioElement.paused &&
|
|
!this.audioElement.ended;
|
|
}
|
|
}
|