Files
ai.interactive.fiction/public/js/kokoro-tts-module.js
T
2025-04-07 06:51:45 +00:00

648 lines
23 KiB
JavaScript

/**
* KokoroTTSModule for AI Interactive Fiction
* Implementation using the Kokoro library
*/
import { TTSHandlerModule } from './tts-handler-module.js';
export class KokoroTTSModule extends TTSHandlerModule {
constructor() {
super('kokoro-tts', 'Kokoro TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
// State
this.iframe = null;
this.currentAudio = null;
this.pendingGenerations = new Map();
this.generationCounter = 0;
this.voices = [];
this.lastProgressTime = null;
this.lastProgressValue = null;
this.modelLoaded = false;
// Bind additional methods beyond those in TTSHandlerModule
this.bindMethods([
'handleIframeMessage',
'setupVoiceFromPreferences',
'generateSpeech',
'speakPreloaded',
'preprocessText',
'pause',
'resume',
'getDefaultVoices'
]);
}
/**
* Initialize the Kokoro TTS module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
console.log('Kokoro TTS: Initializing');
// Get dependencies
this.reportProgress(10, 'Loading dependencies');
// The persistence manager is required for preferences
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
console.error('Kokoro TTS: Required dependency persistence-manager not found');
return false;
}
// Try to check if the kokoro-js.js resource exists before proceeding
try {
this.reportProgress(20, 'Checking for Kokoro TTS resources');
const response = await fetch('/js/kokoro-js.js', { method: 'HEAD' });
if (!response.ok) {
console.error(`Kokoro TTS: Required resource kokoro-js.js not found (${response.status})`);
throw new Error('Kokoro TTS resource not available');
}
console.log('Kokoro TTS: Resources available');
} catch (resourceError) {
console.error('Kokoro TTS: Error checking resources', resourceError);
return false;
}
// Create iframe for Kokoro TTS
this.reportProgress(30, 'Creating Kokoro TTS iframe');
console.log('Kokoro TTS: Creating iframe for Kokoro loader');
const iframe = document.createElement('iframe');
iframe.src = '/kokoro-loader.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);
this.iframe = iframe;
// Wait for iframe to load
try {
await new Promise((resolve, reject) => {
iframe.onload = () => {
console.log('Kokoro TTS: Iframe loaded successfully');
resolve();
};
iframe.onerror = (error) => {
console.error('Kokoro TTS: Iframe failed to load:', error);
reject(new Error('Kokoro TTS: Iframe failed to load'));
};
iframe.onabort = () => {
console.error('Kokoro TTS: Iframe load aborted');
reject(new Error('Kokoro TTS: Iframe load aborted'));
};
});
} catch (iframeError) {
console.error('Kokoro TTS: Error loading iframe:', iframeError);
return false;
}
// Add message event listener for progress updates from iframe
window.addEventListener('message', this.handleIframeMessage);
// Wait for model to initialize
try {
this.reportProgress(50, 'Loading Kokoro model');
console.log('Kokoro TTS: Waiting for model to initialize');
await new Promise((resolve, reject) => {
// Create one-time handler for kokoro:ready message
const readyHandler = (event) => {
if (event.data && event.data.type === 'kokoro:ready') {
window.removeEventListener('message', readyHandler);
// Validate the success status from the event
if (event.data.success === false) {
console.error('Kokoro TTS: Model initialization failed:', event.data.error || 'Unknown error');
reject(new Error('Kokoro TTS: ' + (event.data.error || 'Model initialization failed')));
return;
}
console.log('Kokoro TTS: Model initialized successfully');
this.modelLoaded = true;
this.voices = event.data.voices || this.getDefaultVoices();
resolve();
}
};
window.addEventListener('message', readyHandler);
// Send initialization message to iframe
this.reportProgress(60, 'Initializing Kokoro model');
console.log('Kokoro TTS: Sending initialization message to iframe');
iframe.contentWindow.postMessage({ type: 'kokoro:initialize' }, '*');
});
} catch (modelError) {
console.error('Kokoro TTS: Error initializing model:', modelError);
return false;
}
// Get default voices
this.reportProgress(80, 'Loading Kokoro voices');
this.voices = this.getDefaultVoices();
console.log('Kokoro TTS: Loaded default voices:', this.voices);
// Set voice based on preferences
this.reportProgress(90, 'Setting up voice preferences');
await this.setupVoiceFromPreferences(persistenceManager);
console.log('Kokoro TTS: Voice preferences set up');
this.isReady = true;
this.reportProgress(100, 'Kokoro TTS initialized');
console.log('Kokoro TTS: Initialization complete');
return true;
} catch (error) {
console.error('Kokoro TTS: Initialization error:', error);
this.isReady = 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;
}
// Process message
if (event.data && event.data.type) {
switch (event.data.type) {
case 'kokoro:progress':
if (event.data.progress) {
// Track the last time we received a progress update
this.lastProgressTime = Date.now();
this.lastProgressValue = event.data.progress;
this.modelLoadingProgress = event.data.progress;
// Update progress
this.reportProgress(60 + Math.floor(event.data.progress * 0.3), `Loading Kokoro model: ${event.data.progress.toFixed(0)}%`);
}
break;
case 'kokoro:ready':
// Clear any timeout we might have set
this.modelLoaded = true;
this.reportProgress(90, 'Kokoro model loaded');
console.log('Kokoro TTS: Model ready event received');
break;
case 'kokoro:error':
console.error('Kokoro TTS: Error from iframe:', event.data.error);
// this.changeState('ERROR');
break;
case 'kokoro-generated':
// Handle speech generation completion
if (event.data.id !== undefined && this.pendingGenerations.has(event.data.id)) {
const resolver = this.pendingGenerations.get(event.data.id);
this.pendingGenerations.delete(event.data.id);
if (!event.data.success || event.data.error) {
resolver.reject(new Error(event.data.error || 'Speech generation failed'));
} else {
resolver.resolve({
success: true,
audioData: event.data.result && event.data.result.buffer,
duration: event.data.duration || 0
});
}
}
break;
case 'kokoro:voices':
// Update available voices
if (Array.isArray(event.data.voices)) {
this.voices = event.data.voices;
document.dispatchEvent(new CustomEvent('tts:voices-updated', {
detail: { engine: 'kokoro', voices: this.voices }
}));
}
break;
}
}
}
/**
* Set up the voice from preferences
*/
async setupVoiceFromPreferences(persistenceManager) {
if (!persistenceManager) {
return false;
}
// Get current locale
const localization = this.getModule('localization');
const locale = localization ? localization.getLocale() : null;
// Get preferred voice from preferences
const preferredVoiceId = persistenceManager.getPreference('tts', 'kokoro_voice', '');
// Find matching voice
let selectedVoice = null;
if (preferredVoiceId) {
// Try to find the specific voice
selectedVoice = this.voices.find(v => v.id === preferredVoiceId);
}
if (!selectedVoice) {
// Find a voice for the current locale
const normalizedLocale = locale ? locale.toLowerCase().replace('_', '-') : 'en-us';
const languageCode = normalizedLocale.split('-')[0];
// Try to find an exact locale match
selectedVoice = this.voices.find(v =>
v.lang && v.lang.toLowerCase() === normalizedLocale
);
// If not found, try to find a language match
if (!selectedVoice) {
selectedVoice = this.voices.find(v =>
v.lang && v.lang.toLowerCase().startsWith(languageCode)
);
}
// If still not found, use the first voice
if (!selectedVoice && this.voices.length > 0) {
selectedVoice = this.voices[0];
}
}
// Set the voice
if (selectedVoice) {
this.setVoice(selectedVoice);
return true;
}
return false;
}
/**
* Set voice for TTS
* @param {Object} voice - Voice to set
* @returns {boolean} - Success status
*/
setVoice(voice) {
if (!voice || !voice.id) {
return false;
}
this.currentVoice = voice;
// Save to preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'kokoro_voice', voice.id);
}
// Send message to iframe
if (this.iframe && this.iframe.contentWindow) {
this.iframe.contentWindow.postMessage({
type: 'kokoro:set-voice',
voiceId: voice.id
}, '*');
}
return true;
}
/**
* Set options for TTS
* @param {Object} options - Options to set
* @returns {boolean} - Success status
*/
setOptions(options) {
if (!options) {
return false;
}
// Update rate and volume if provided
if (options.rate !== undefined) {
this.options.rate = options.rate;
}
if (options.volume !== undefined) {
this.options.volume = options.volume;
}
return true;
}
/**
* Get available voices
* @returns {Array} - Array of voice objects
*/
async getVoices() {
// If no voices are loaded yet, return default voices
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) {
// Remove HTML tags
text = text.replace(/<[^>]*>/g, ' ');
// Replace special characters
text = text.replace(/&/g, ' and ');
// Normalize whitespace
text = text.replace(/\s+/g, ' ').trim();
return text;
}
/**
* Preload speech for later playback
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Resolves with preloaded audio data
*/
async preloadSpeech(text) {
if (!this.isReady) {
return { success: false, reason: 'not_ready' };
}
// Generate speech audio data
const result = await this.generateSpeech(text);
if (!result.success) {
return { success: false, reason: 'generation_failed' };
}
return {
success: true,
audioData: result.audioData,
text,
duration: result.duration || 0
};
}
/**
* 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.isReady || !preloadData || !preloadData.audioData) {
if (callback) {
callback({ success: false, reason: 'invalid_data' });
}
return false;
}
// Stop any ongoing speech
this.stop();
// Create audio from blob
const audioBlob = new Blob([preloadData.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.playbackRate = this.options.rate;
// 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;
audio.play().catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
}
URL.revokeObjectURL(audioUrl);
});
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;
}
// Preprocess text
const processedText = this.preprocessText(text);
// Generate and play speech
this.generateSpeech(processedText).then(result => {
if (result.success && result.audioData) {
// Create audio blob and URL
const audioBlob = new Blob([result.audioData], { type: 'audio/mp3' });
const audioUrl = URL.createObjectURL(audioBlob);
// Stop any ongoing speech
this.stop();
// Create and play audio
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.playbackRate = this.options.rate;
// 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;
audio.play().catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
}
});
} else {
if (callback) {
callback({ success: false, reason: 'generation_failed' });
}
}
}).catch(error => {
if (callback) {
callback({ success: false, reason: 'generation_error', error });
}
});
return true;
}
/**
* 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.isReady || !this.iframe || !this.iframe.contentWindow) {
return { success: false, reason: 'not_ready' };
}
// Process text
const processedText = this.preprocessText(text);
return new Promise((resolve, reject) => {
// Generate unique ID for this request
const id = this.generationCounter++;
// Store resolver functions
this.pendingGenerations.set(id, { resolve, reject });
// Send request to iframe
this.iframe.contentWindow.postMessage({
type: 'kokoro-generate',
text: processedText,
id,
voice: this.currentVoice ? this.currentVoice.id : null
}, '*');
});
}
/**
* Stop current speech
* @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('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() {
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' }
];
}
}
const kokoroTTSModule = new KokoroTTSModule();
export { kokoroTTSModule };