Fix Kokoro TTS integration issues: Remove API key requirement and ensure system-specific options display correctly
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* API TTS Module Base Class
|
||||
* Base class for API-based TTS modules
|
||||
*/
|
||||
import { TTSHandlerModule } from './tts-handler-module.js';
|
||||
|
||||
export class ApiTTSModuleBase extends TTSHandlerModule {
|
||||
constructor(id, name) {
|
||||
super(id, name);
|
||||
|
||||
// Basic voice options
|
||||
this.voiceOptions = {
|
||||
speed: 1.0,
|
||||
voice: null
|
||||
};
|
||||
|
||||
// API settings
|
||||
this.apiKey = '';
|
||||
this.apiBaseUrl = '';
|
||||
|
||||
// State
|
||||
this.currentAudio = null;
|
||||
|
||||
// Bind additional methods
|
||||
this.bindMethods([
|
||||
'handleApiKeyChanged',
|
||||
'handleApiUrlChanged',
|
||||
'speakPreloaded',
|
||||
'loadVoices',
|
||||
'selectVoiceForLocale',
|
||||
'selectDefaultVoice',
|
||||
'generateSpeechAudio',
|
||||
'preprocessText'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API TTS module
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async initialize() {
|
||||
this.reportProgress(10, `Initializing ${this.name}`);
|
||||
|
||||
// Initialize parent
|
||||
const parentInit = await super.initialize();
|
||||
if (!parentInit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get persistence manager
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
console.error(`${this.name}: Required dependency 'persistence-manager' not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load API key from preferences
|
||||
this.apiKey = persistenceManager.getPreference('tts', `${this.id}_api_key`) || '';
|
||||
|
||||
// Get default API URL
|
||||
const defaultApiUrl = this.getDefaultApiBaseUrl();
|
||||
|
||||
// Set up API base URL from preferences or use default
|
||||
const savedApiUrl = persistenceManager.getPreference('tts', `${this.id}_api_url`);
|
||||
this.apiBaseUrl = savedApiUrl || defaultApiUrl;
|
||||
|
||||
// If no API URL was saved in preferences, save the default
|
||||
if (!savedApiUrl && defaultApiUrl) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, defaultApiUrl);
|
||||
}
|
||||
|
||||
this.reportProgress(30, `${this.name} API configuration loaded`);
|
||||
|
||||
// Set up event listeners for API key and URL changes
|
||||
document.addEventListener('tts:api:keyChanged', this.handleApiKeyChanged);
|
||||
document.addEventListener('tts:api:urlChanged', this.handleApiUrlChanged);
|
||||
|
||||
// Load voices
|
||||
await this.loadVoices();
|
||||
this.reportProgress(50, `${this.name} voices loaded`);
|
||||
|
||||
// Set up voice from preferences
|
||||
await this.setupVoiceFromPreferences();
|
||||
this.reportProgress(70, `${this.name} voice preferences configured`);
|
||||
|
||||
// Check if we have an API key
|
||||
this.isReady = !!this.apiKey;
|
||||
|
||||
// Always mark as available for UI configuration purposes
|
||||
// (even if not ready due to missing API key)
|
||||
this.reportProgress(100, `${this.name} initialization complete`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default API base URL for this provider
|
||||
* @returns {string} - Default API base URL
|
||||
*/
|
||||
getDefaultApiBaseUrl() {
|
||||
// To be implemented by subclasses
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up voice based on preferences and locale
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async setupVoiceFromPreferences() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
const localization = this.getModule('localization');
|
||||
|
||||
if (!persistenceManager || !localization) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get preferred voice ID from preferences
|
||||
const preferredVoiceId = persistenceManager.getPreference('tts', `${this.id}_voice`, '');
|
||||
|
||||
// Get current locale
|
||||
const currentLocale = localization.getLocale();
|
||||
|
||||
// If we have a preferred voice and available voices, use it
|
||||
if (preferredVoiceId && this.voices && this.voices.length > 0) {
|
||||
const voice = this.voices.find(v => v.id === preferredVoiceId);
|
||||
if (voice) {
|
||||
this.voiceOptions.voice = voice;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise select a voice based on locale
|
||||
if (currentLocale) {
|
||||
return this.selectVoiceForLocale(currentLocale);
|
||||
}
|
||||
|
||||
// Fall back to default voice
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load available voices from API
|
||||
* @returns {Promise<boolean>} - Resolves with success status
|
||||
*/
|
||||
async loadVoices() {
|
||||
// To be implemented by subclasses
|
||||
this.voices = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a voice for the given locale
|
||||
* @param {string} locale - Locale code
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectVoiceForLocale(locale) {
|
||||
// To be implemented by subclasses
|
||||
return this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a default voice
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
selectDefaultVoice() {
|
||||
if (this.voices && this.voices.length > 0) {
|
||||
this.voiceOptions.voice = this.voices[0];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech audio blob for the given text using the API.
|
||||
* @param {string} text - The text to synthesize.
|
||||
* @returns {Promise<Object>} - A promise that resolves with the audio data object.
|
||||
*/
|
||||
async generateSpeechAudio(text) {
|
||||
// To be implemented by subclasses
|
||||
return { success: false, reason: 'not_implemented' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak preloaded audio data
|
||||
* @param {Object} preloadData - Preloaded audio data
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speakPreloaded(preloadData, callback = null) {
|
||||
if (!preloadData || !preloadData.audioData) {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'invalid_data' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop any ongoing speech
|
||||
this.stop();
|
||||
|
||||
// Create audio blob
|
||||
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// Create audio element
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
// Set up event handlers
|
||||
audio.onended = () => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: true });
|
||||
}
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
// Start playback
|
||||
this.currentAudio = audio;
|
||||
this.isSpeaking = true;
|
||||
|
||||
// Handle play error
|
||||
audio.play().catch(error => {
|
||||
this.isSpeaking = false;
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'playback_error', error });
|
||||
}
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop speaking
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
stop() {
|
||||
if (this.currentAudio) {
|
||||
try {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
this.currentAudio = null;
|
||||
this.isSpeaking = false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`${this.name}: Error stopping speech:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Function} callback - Callback for when speech completes
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
speak(text, callback = null) {
|
||||
if (!this.isReady) {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'not_ready' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate and play speech
|
||||
this.generateSpeechAudio(text).then(result => {
|
||||
if (result.success && result.audioData) {
|
||||
// Create audio from blob and play it
|
||||
this.speakPreloaded({ audioData: result.audioData }, callback);
|
||||
} else if (callback) {
|
||||
callback({ success: false, reason: 'generation_failed' });
|
||||
}
|
||||
}).catch(error => {
|
||||
if (callback) {
|
||||
callback({ success: false, reason: 'generation_error', error });
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload speech for later playback
|
||||
* @param {string} text - Text to preload
|
||||
* @returns {Promise<Object>} - Preloaded speech data
|
||||
*/
|
||||
async preloadSpeech(text) {
|
||||
if (!this.isReady) {
|
||||
return { success: false, reason: 'not_ready' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate speech
|
||||
const result = await this.generateSpeechAudio(text);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, reason: 'generation_failed' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioData: result.audioData,
|
||||
text,
|
||||
duration: result.duration || 0
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, reason: 'generation_error', error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text for TTS
|
||||
* @param {string} text - Text to preprocess
|
||||
* @returns {string} - Processed text
|
||||
*/
|
||||
preprocessText(text) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove HTML tags
|
||||
let processed = text.replace(/<[^>]*>/g, ' ');
|
||||
|
||||
// Replace special characters
|
||||
processed = processed.replace(/&/g, ' and ');
|
||||
|
||||
// Normalize whitespace
|
||||
processed = processed.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Add trailing period if missing
|
||||
if (!/[.!?]$/.test(processed)) {
|
||||
processed += '.';
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API key change event
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
handleApiKeyChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const newKey = event.detail.key || '';
|
||||
|
||||
// Security check - never use a URL as an API key
|
||||
if (newKey && newKey.startsWith('http')) {
|
||||
console.error(`${this.name}: Received URL instead of API key, ignoring it`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update API key
|
||||
this.apiKey = newKey;
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_key`, newKey);
|
||||
}
|
||||
|
||||
// Update ready state
|
||||
this.isReady = !!this.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API URL change event
|
||||
* @param {Event} event - Event object
|
||||
*/
|
||||
handleApiUrlChanged(event) {
|
||||
if (event && event.detail && event.detail.provider === this.id) {
|
||||
const newUrl = event.detail.url || this.getDefaultApiBaseUrl();
|
||||
|
||||
// Update API URL
|
||||
this.apiBaseUrl = newUrl;
|
||||
|
||||
// Save to preferences
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (persistenceManager) {
|
||||
persistenceManager.updatePreference('tts', `${this.id}_api_url`, newUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user