Files
ai.interactive.fiction/public/js/kokoro-handler.js
T

789 lines
30 KiB
JavaScript

/**
* Kokoro TTS Handler
* Handles text-to-speech using the Kokoro library
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class KokoroHandler extends TTSHandler {
/**
* Constructor
* @param {Object} options - Options for the handler
*/
constructor(options = {}) {
super(options);
// Set default options
this.options = {
rate: 1.0,
volume: 1.0,
...options
};
// Initialize properties
this.id = 'kokoro';
this.name = 'Kokoro TTS Handler';
this.available = false;
this.loading = false;
this.iframe = null;
this.currentAudio = null;
this.currentVoice = null;
this.pendingGenerations = new Map();
this.generationCounter = 0;
// Default voices (will be replaced by dynamically fetched voices)
this.voices = [];
// Dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.initialize = this.initialize.bind(this);
this.speak = this.speak.bind(this);
this.stop = this.stop.bind(this);
this.getVoices = this.getVoices.bind(this);
this.setVoice = this.setVoice.bind(this);
this.generateSpeech = this.generateSpeech.bind(this);
this.preprocessText = this.preprocessText.bind(this);
this.speakPreloaded = this.speakPreloaded.bind(this);
this.preloadSpeech = this.preloadSpeech.bind(this);
this.pause = this.pause.bind(this);
this.resume = this.resume.bind(this);
this.setOptions = this.setOptions.bind(this);
this.setupVoiceFromPreferences = this.setupVoiceFromPreferences.bind(this);
this.getId = this.getId.bind(this);
this.handleIframeMessage = this.handleIframeMessage.bind(this);
}
/**
* Get the ID of the handler
* @returns {string} - Handler ID
*/
getId() {
return 'kokoro';
}
/**
* Get a module from the registry
* @param {string} id - Module ID
* @returns {Object} - Module instance
*/
getModule(id) {
return moduleRegistry.getModule(id);
}
/**
* Initialize the handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback) {
try {
console.log('Kokoro TTS: Initializing...');
// Check if already initialized
if (this.available && this.isReady) {
console.log('Kokoro TTS: Already initialized and ready');
return true;
}
// Ensure we have at least default voices ready
if (!this.voices || this.voices.length === 0) {
console.log('Kokoro TTS: No voices set, initializing with defaults');
this.voices = this.getDefaultVoices();
}
// Set loading flag
this.loading = true;
this.isReady = false; // Explicitly set to false during initialization
// Create iframe if not already created
if (!this.iframe) {
console.log('Kokoro TTS: Creating iframe');
// Create iframe
this.iframe = document.createElement('iframe');
this.iframe.style.display = 'none';
this.iframe.src = '/kokoro-loader.html';
document.body.appendChild(this.iframe);
// Add message listener - IMPORTANT: Use an arrow function to preserve 'this'
window.addEventListener('message', (event) => this.handleIframeMessage(event));
}
// Set up event handler for configuration changes
document.addEventListener('tts:configure', (event) => {
if (event.detail) {
if (typeof event.detail.rate === 'number') {
this.options.rate = event.detail.rate;
console.log(`Kokoro TTS: Rate updated to ${this.options.rate}`);
}
if (typeof event.detail.volume === 'number') {
this.options.volume = event.detail.volume;
console.log(`Kokoro TTS: Volume updated to ${this.options.volume}`);
}
}
});
// Wait for Kokoro to load
return new Promise((resolve) => {
// Set a timeout to prevent hanging indefinitely
const timeout = setTimeout(() => {
console.error('Kokoro TTS: Initialization timed out');
this.loading = false;
this.isReady = false;
this.available = false;
resolve(false);
}, 30000); // 30 second timeout
// Handle progress updates
const handleProgress = (progress, message) => {
console.log(`Kokoro TTS: Progress ${progress * 100}% - ${message}`);
if (progressCallback) {
progressCallback(progress, message);
}
};
// Handle message events
const messageHandler = (event) => {
if (event.source !== this.iframe.contentWindow) {
return;
}
const data = event.data;
if (data.type === 'kokoro-progress') {
handleProgress(data.progress, data.message);
} else if (data.type === 'kokoro-ready') {
console.log('Kokoro TTS: Received ready message from iframe', data);
// Remove the message listener
window.removeEventListener('message', messageHandler);
// Clear the timeout
clearTimeout(timeout);
// Set availability based on success
this.available = data.success;
this.loading = false;
this.isReady = data.success; // Set isReady flag based on success
// Store voices if provided
if (data.success && data.voices && Array.isArray(data.voices)) {
console.log(`Kokoro TTS: Received ${data.voices.length} voices from Kokoro iframe during initialization`);
this.voices = data.voices;
} else {
console.warn('Kokoro TTS: No voices received during initialization or invalid voices data');
if (data.success) {
// Even though we already set the default voices, check and update if needed
if (!this.voices || this.voices.length === 0) {
this.voices = this.getDefaultVoices();
console.log('Kokoro TTS: Using default voices as fallback');
}
}
}
// Set up voice from preferences
if (data.success) {
this.setupVoiceFromPreferences().then(() => {
console.log('Kokoro TTS: Voice set up from preferences during initialization');
}).catch(error => {
console.error('Kokoro TTS: Error setting up voice from preferences during initialization:',
error ? (error.message || error) : 'Unknown error');
});
}
// Resolve with success status
resolve(data.success);
}
};
// Add the message handler
window.addEventListener('message', messageHandler);
// Initial progress update
handleProgress(0.1, 'Starting Kokoro initialization');
});
} catch (error) {
console.error('Kokoro TTS: Error during initialization:', error);
this.loading = false;
this.isReady = false;
this.available = false;
return false;
}
}
/**
* Handle messages from the iframe
* @param {MessageEvent} event - Message event
*/
handleIframeMessage(event) {
// Only process messages from our iframe
if (!this.iframe || event.source !== this.iframe.contentWindow) {
return;
}
const data = event.data;
console.log('Kokoro TTS: Received message from iframe:', data.type);
switch (data.type) {
case 'kokoro-log':
console.log(`Kokoro Loader: ${data.message}`);
break;
case 'kokoro-ready':
console.log('Kokoro TTS: Received ready message from iframe. Success:', data.success, 'Voices:', data.voices ? data.voices.length : 0);
// Store availability
this.loading = false;
this.available = data.success;
this.isReady = data.success; // Important to set this for the base handler
// Store voices
if (data.success && data.voices && Array.isArray(data.voices)) {
console.log(`Kokoro TTS: Storing ${data.voices.length} voices from iframe`);
this.voices = data.voices;
} else if (data.success) {
// If success but no voices, use defaults
console.warn('Kokoro TTS: No voices received from iframe, using defaults');
this.voices = this.getDefaultVoices();
}
// Set up voice from preferences if ready
if (this.available) {
this.setupVoiceFromPreferences().then(() => {
console.log('Kokoro TTS: Voice set up from preferences');
});
}
// Dispatch ready event
this.dispatchEvent('tts:ready', { success: data.success });
break;
case 'kokoro-generated':
// Handle generated speech
if (data.id && this.pendingGenerations.has(data.id)) {
const { resolve, reject } = this.pendingGenerations.get(data.id);
this.pendingGenerations.delete(data.id);
if (data.success && data.result) {
// Create an audio element from the result
try {
// Create a blob from the buffer
const blob = new Blob([data.result.buffer], { type: 'audio/wav' });
// Create audio element
const audio = new Audio(URL.createObjectURL(blob));
// Create a play function
const play = () => {
audio.play().catch(error => {
console.error('Error playing Kokoro audio:', error);
});
};
resolve({ audio, play, blob });
} catch (error) {
console.error('Error processing Kokoro audio:', error);
reject(error);
}
} else {
console.error('Kokoro TTS: Invalid speech generation result');
reject(new Error(data.error || 'Unknown error generating speech'));
}
}
break;
case 'kokoro-progress':
// Progress updates are handled during initialization
break;
}
}
/**
* Set up the voice from preferences
* @returns {Promise<void>}
*/
async setupVoiceFromPreferences() {
try {
console.log('Kokoro TTS: Setting up voice from preferences, available voices:', this.voices ? this.voices.length : 0);
// If no voices are available yet, use default voice
if (!this.voices || this.voices.length === 0) {
console.warn('Kokoro TTS: No voices available yet, using default voice');
return;
}
// Get persistence manager
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
console.warn('Kokoro TTS: Persistence manager not available');
this.currentVoice = this.voices[0]; // Default to first voice
return;
}
// Get localization
const localization = this.getModule('localization');
if (!localization) {
console.warn('Kokoro TTS: Localization not available');
this.currentVoice = this.voices[0]; // Default to first voice
return;
}
// Get current locale
let currentLocale = 'en-us'; // Default locale
if (localization && typeof localization.getLocale === 'function') {
currentLocale = localization.getLocale();
console.log('Kokoro TTS: Current locale from localization:', currentLocale);
} else {
console.warn('Kokoro TTS: getLocale method not available, using default locale');
}
// Get voice preference
const voiceId = persistenceManager.getPreference('tts-voice-kokoro');
console.log('Kokoro TTS: Preferred voice ID:', voiceId);
// Find voice
if (voiceId) {
const voice = this.voices.find(v => v.id === voiceId);
if (voice) {
console.log('Kokoro TTS: Found preferred voice:', voice.id, voice.name);
this.currentVoice = voice;
return;
} else {
console.warn('Kokoro TTS: Preferred voice not found:', voiceId);
}
}
// Find voice for current locale
if (currentLocale) {
// Standardize locale format (compare lowercase and handle hyphens/underscores)
const normalizedLocale = currentLocale.toLowerCase().replace('_', '-');
const localePrefix = normalizedLocale.split('-')[0]; // Get language prefix (en, de, etc.)
// First try exact locale match
let localeVoice = this.voices.find(v => v.lang && v.lang.toLowerCase().replace('_', '-') === normalizedLocale);
// If no exact match, try prefix match (en-US with en-GB for example)
if (!localeVoice) {
localeVoice = this.voices.find(v => {
if (!v.lang) return false;
const voiceLocale = v.lang.toLowerCase().replace('_', '-');
return voiceLocale.startsWith(localePrefix + '-');
});
}
if (localeVoice) {
console.log('Kokoro TTS: Found locale voice:', localeVoice.id, localeVoice.name, 'for locale:', normalizedLocale);
this.currentVoice = localeVoice;
return;
} else {
console.warn('Kokoro TTS: No voice found for locale:', normalizedLocale);
}
}
// Default to first voice if available
if (this.voices.length > 0) {
console.log('Kokoro TTS: Using first available voice:', this.voices[0].id, this.voices[0].name);
this.currentVoice = this.voices[0];
} else {
console.warn('Kokoro TTS: No voices available after all checks');
}
} catch (error) {
// Log detailed error information
console.error('Kokoro TTS: Error setting up voice from preferences:', error ? error.message || error : 'Unknown error');
// Default to first voice if available
if (this.voices && this.voices.length > 0) {
console.log('Kokoro TTS: Falling back to first voice after error');
this.currentVoice = this.voices[0];
} else {
console.warn('Kokoro TTS: No voices available to fall back to after error');
}
}
}
/**
* Set voice for TTS
* @param {Object} voice - Voice to set
* @returns {boolean} - Success status
*/
setVoice(voice) {
if (!voice || !voice.id) {
return false;
}
// Find voice
const foundVoice = this.voices.find(v => v.id === voice.id);
if (!foundVoice) {
return false;
}
// Set voice
this.currentVoice = foundVoice;
// Save preference
try {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts-voice-kokoro', foundVoice.id);
}
} catch (error) {
console.error('Kokoro TTS: Error saving voice preference:', error);
}
return true;
}
/**
* Set options for TTS
* @param {Object} options - Options to set
* @returns {boolean} - Success status
*/
setOptions(options) {
if (!options) {
return false;
}
// Update options
this.options = {
...this.options,
...options
};
return true;
}
/**
* Get available voices
* @returns {Array} - Array of voice objects
*/
getVoices() {
if (!this.voices || this.voices.length === 0) {
return this.getDefaultVoices();
}
return this.voices;
}
/**
* Preprocess text for TTS
* @param {string} text - Text to preprocess
* @returns {string} - Preprocessed text
*/
preprocessText(text) {
if (!text) {
return '';
}
// Remove HTML tags
let processed = text.replace(/<[^>]*>/g, '');
// Replace special characters
processed = processed.replace(/&nbsp;/g, ' ');
processed = processed.replace(/&amp;/g, '&');
processed = processed.replace(/&lt;/g, '<');
processed = processed.replace(/&gt;/g, '>');
processed = processed.replace(/&quot;/g, '"');
processed = processed.replace(/&#39;/g, "'");
return processed;
}
/**
* Preload speech for later playback
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Resolves with preloaded audio data
*/
async preloadSpeech(text) {
if (!this.available) {
console.warn('Kokoro TTS: Not available');
return null;
}
try {
// No longer check the local cache as we're using TTSFactory's centralized cache
// Generate speech directly
const result = await this.generateSpeech(text);
// Return result for centralized caching in TTSFactory
return result;
} catch (error) {
console.error('Kokoro 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) {
console.warn('Kokoro TTS: Not available');
return false;
}
try {
// Stop any current speech
this.stop();
// Create audio element if not already created
const audio = preloadData.audio;
// Set up event handlers
audio.onended = () => {
this.currentAudio = null;
if (callback) callback();
};
audio.onerror = (error) => {
console.error('Kokoro TTS: Audio playback error:', error);
this.currentAudio = null;
if (callback) callback(error);
};
// Set volume
audio.volume = this.options.volume;
// Store current audio
this.currentAudio = audio;
// Play audio
if (preloadData.play) {
preloadData.play();
} else {
audio.play().catch(error => {
console.error('Kokoro TTS: Error playing audio:', error);
this.currentAudio = null;
if (callback) callback(error);
});
}
return true;
} catch (error) {
console.error('Kokoro TTS: Error speaking preloaded audio:', error);
return false;
}
}
/**
* Speak text
* @param {string} text - Text to speak
* @param {Object} options - Speech options
* @returns {Promise<boolean>} - Resolves with success status
*/
async speak(text, options = {}) {
if (!this.available) {
console.warn('Kokoro TTS: Not available');
return false;
}
try {
// Stop any current speech
this.stop();
console.log('Kokoro TTS: Generating speech for:', text);
// Generate speech
const result = await this.generateSpeech(text);
if (!result || !result.audio) {
console.error('Kokoro TTS: Invalid speech generation result');
return false;
}
// Set up event handlers
result.audio.onended = () => {
console.log('Kokoro TTS: Audio playback ended');
this.currentAudio = null;
// Dispatch event for completion
window.dispatchEvent(new CustomEvent('tts:speak-completed'));
};
result.audio.onerror = (error) => {
console.error('Kokoro TTS: Audio playback error:', error);
this.currentAudio = null;
// Dispatch event for error
window.dispatchEvent(new CustomEvent('tts:speak-error', {
detail: { error: error }
}));
};
// Set volume
result.audio.volume = this.options.volume;
// Store current audio
this.currentAudio = result.audio;
console.log('Kokoro TTS: Attempting to play audio');
// Play audio with better error handling
try {
if (result.play && typeof result.play === 'function') {
await result.play();
} else {
await result.audio.play();
}
console.log('Kokoro TTS: Audio playback started successfully');
return true;
} catch (playError) {
console.error('Error playing Kokoro audio:', playError);
this.currentAudio = null;
return false;
}
} catch (error) {
console.error('Kokoro TTS: Error speaking:', error);
return false;
}
}
/**
* Generate speech using the iframe
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Resolves with audio data
*/
async generateSpeech(text) {
if (!this.iframe || !this.iframe.contentWindow) {
throw new Error('Kokoro iframe not initialized');
}
// Preprocess text
const processedText = this.preprocessText(text);
// Ensure we have a valid voice
let voiceId = 'af_heart'; // Default fallback
if (this.currentVoice && this.currentVoice.id) {
voiceId = this.currentVoice.id;
} else if (this.voices && this.voices.length > 0) {
// Default to first voice if none selected
this.currentVoice = this.voices[0];
voiceId = this.currentVoice.id;
console.log(`Kokoro TTS: No voice set, defaulting to ${voiceId}`);
}
console.log(`Kokoro TTS: Generating speech with voice ${voiceId}`);
return new Promise((resolve, reject) => {
// Generate unique ID for this request
const id = `gen-${++this.generationCounter}`;
// Store the pending generation
this.pendingGenerations.set(id, { resolve, reject });
// Send the generation request to the iframe
this.iframe.contentWindow.postMessage({
type: 'kokoro-generate',
id: id,
text: processedText,
voice: voiceId,
speed: this.options.rate
}, '*');
});
}
/**
* Stop current speech
* @returns {boolean} - Success status
*/
stop() {
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudio = null;
return true;
} catch (error) {
console.error('Kokoro TTS: Error stopping speech:', error);
return false;
}
}
return true;
}
/**
* Pause current speech
* @returns {boolean} - Success status
*/
pause() {
if (this.currentAudio) {
try {
this.currentAudio.pause();
return true;
} catch (error) {
console.error('Kokoro TTS: Error pausing speech:', error);
return false;
}
}
return true;
}
/**
* Resume current speech
* @returns {boolean} - Success status
*/
resume() {
if (this.currentAudio) {
try {
this.currentAudio.play();
return true;
} catch (error) {
console.error('Kokoro TTS: Error resuming speech:', error);
return false;
}
}
return false;
}
/**
* Get default voices for current locale
* @returns {Array} Default voices
*/
getDefaultVoices() {
// Check if localization module is available
const localization = this.getModule('localization');
let locale = 'en-us'; // Default fallback
if (localization) {
locale = localization.getLocale();
console.log(`Kokoro TTS: Getting default voices for locale: ${locale}`);
} else {
console.log('Kokoro TTS: Localization module not available, using default locale: en-us');
}
// Use the actual voices defined in the Kokoro loader
return [
// American Female voices
{ id: 'af_heart', name: 'Heart', lang: 'en-US', gender: 'female' },
{ id: 'af_daisy', name: 'Daisy', lang: 'en-US', gender: 'female' },
{ id: 'af_soft', name: 'Soft', lang: 'en-US', gender: 'female' },
{ id: 'af_glados', name: 'GLaDOS', lang: 'en-US', gender: 'female' },
{ id: 'af_southern_belle', name: 'Southern Belle', lang: 'en-US', gender: 'female' },
{ id: 'af_dramatic', name: 'Dramatic', lang: 'en-US', gender: 'female' },
{ id: 'af_valley_girl', name: 'Valley Girl', lang: 'en-US', gender: 'female' },
{ id: 'af_british', name: 'British', lang: 'en-US', gender: 'female' },
{ id: 'af_russian', name: 'Russian', lang: 'en-US', gender: 'female' },
{ id: 'af_german', name: 'German', lang: 'en-US', gender: 'female' },
{ id: 'af_cheeky_cute', name: 'Cheeky Cute', lang: 'en-US', gender: 'female' },
// American Male voices
{ id: 'am_bruce', name: 'Bruce', lang: 'en-US', gender: 'male' },
{ id: 'am_announcer', name: 'Announcer', lang: 'en-US', gender: 'male' },
{ id: 'am_radio_host', name: 'Radio Host', lang: 'en-US', gender: 'male' },
// British Female voices
{ id: 'bf_charlotte', name: 'Charlotte', lang: 'en-GB', gender: 'female' },
{ id: 'bf_elizabeth', name: 'Elizabeth', lang: 'en-GB', gender: 'female' },
{ id: 'bf_lily', name: 'Lily', lang: 'en-GB', gender: 'female' },
{ id: 'bf_olivia', name: 'Olivia', lang: 'en-GB', gender: 'female' },
{ id: 'bf_victoria', name: 'Victoria', lang: 'en-GB', gender: 'female' },
// British Male voices
{ id: 'bm_william', name: 'William', lang: 'en-GB', gender: 'male' },
{ id: 'bm_arthur', name: 'Arthur', lang: 'en-GB', gender: 'male' },
{ id: 'bm_george', name: 'George', lang: 'en-GB', gender: 'male' },
{ id: 'bm_harry', name: 'Harry', lang: 'en-GB', gender: 'male' },
{ id: 'bm_jack', name: 'Jack', lang: 'en-GB', gender: 'male' }
];
}
}