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

793 lines
28 KiB
JavaScript

/**
* Kokoro TTS Handler
* Provides neural TTS via Kokoro.js
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class KokoroHandler extends TTSHandler {
constructor() {
super();
this.id = 'kokoro';
this.name = 'Kokoro TTS Handler';
// Kokoro instance
this.kokoro = null;
// Available voices
this.voices = [
{ id: 'de_DE-neural', name: 'German (Neural)', lang: 'de-DE' },
{ id: 'en_US-neural', name: 'English (Neural)', lang: 'en-US' }
];
// Current voice
this.currentVoice = null;
// Voice options
this.options = {
volume: 1.0,
rate: 1.0,
pitch: 1.0
};
// State
this.available = false;
this.loading = false;
this.currentAudio = null;
this.preloadCache = new Map();
this.worker = null;
this.pendingGeneration = null;
// Dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'pause',
'resume',
'getVoices',
'setVoice',
'setOptions',
'setupVoiceFromPreferences',
'getId',
'getModule'
]);
}
/**
* Initialize the Kokoro TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
if (this.loading) {
return new Promise((resolve) => {
// Wait for loading to complete
this.addEventListener(document, 'kokoro-loading-complete', (event) => {
resolve(event.detail?.success || false);
}, { once: true });
});
}
if (this.available) {
return true;
}
this.loading = true;
try {
// Report progress
if (progressCallback) {
progressCallback(10, "Loading Kokoro TTS");
}
// Initialize web worker
try {
if (progressCallback) {
progressCallback(20, "Initializing Kokoro worker");
}
// Create worker
this.worker = new Worker('/js/kokoro-worker.js');
// Set up message handler
this.worker.onmessage = (e) => {
const message = e.data;
switch (message.type) {
case 'ready':
console.log('Kokoro worker is ready');
break;
case 'generated':
// Handle generated speech
if (this.pendingGeneration) {
const { text, resolve, reject } = this.pendingGeneration;
this.pendingGeneration = null;
try {
// Create audio from the returned buffer
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const audioBuffer = audioContext.createBuffer(
1, // mono
message.result.audio.length,
message.result.sampling_rate
);
// Copy the audio data to the buffer
const channelData = audioBuffer.getChannelData(0);
channelData.set(new Float32Array(message.result.audio));
// Create audio element
const audio = new Audio();
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
// Connect to destination
source.connect(audioContext.destination);
// Create a play function
const play = () => {
source.start(0);
};
resolve({ audio, play, buffer: audioBuffer });
} catch (error) {
console.error('Error processing Kokoro audio:', error);
reject(error);
}
}
break;
case 'error':
console.error('Kokoro worker error:', message.error);
if (this.pendingGeneration) {
const { reject } = this.pendingGeneration;
this.pendingGeneration = null;
reject(new Error(message.error));
}
break;
case 'progress':
if (progressCallback) {
const progress = 20 + Math.round(message.progress * 80);
progressCallback(progress, `Loading Kokoro model: ${Math.round(message.progress * 100)}%`);
}
break;
}
};
// Set up error handler
this.worker.onerror = (error) => {
console.error('Kokoro worker error:', error);
if (this.pendingGeneration) {
const { reject } = this.pendingGeneration;
this.pendingGeneration = null;
reject(error);
}
};
// Initialize the worker
this.worker.postMessage({ type: 'init' });
// Wait for worker to be ready
await new Promise((resolve, reject) => {
// Set up message handler for initialization
const messageHandler = (e) => {
const message = e.data;
if (message.type === 'ready') {
this.worker.removeEventListener('message', messageHandler);
resolve();
} else if (message.type === 'error') {
this.worker.removeEventListener('message', messageHandler);
reject(new Error(message.error));
}
};
this.worker.addEventListener('message', messageHandler);
// Set timeout for initialization
setTimeout(() => {
this.worker.removeEventListener('message', messageHandler);
reject(new Error('Timeout initializing Kokoro worker'));
}, 30000);
});
console.log('Kokoro worker initialized successfully');
// Mark as available
this.available = true;
// Set up voice based on preferences and locale
await this.setupVoiceFromPreferences();
// Report progress
if (progressCallback) {
progressCallback(100, "Kokoro TTS ready");
}
// Dispatch event
this.dispatchEvent('kokoro-loading-complete', { success: true });
this.loading = false;
return true;
} catch (error) {
console.error('Error initializing Kokoro worker:', error);
// Try to fall back to direct method
try {
if (progressCallback) {
progressCallback(20, "Initializing Kokoro directly");
}
// Load Kokoro script
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/js/kokoro.js';
script.onload = () => {
if (window.kokoro) {
this.kokoro = window.kokoro;
resolve();
} else {
reject(new Error('Kokoro not found after script load'));
}
};
script.onerror = (e) => {
reject(new Error('Error loading Kokoro script'));
};
document.head.appendChild(script);
// Set timeout
setTimeout(() => {
reject(new Error('Timeout loading Kokoro script'));
}, 10000);
});
// Function to report progress
const progress = (progress) => {
if (progressCallback) {
const scaledProgress = 20 + Math.round(progress * 80);
progressCallback(scaledProgress, `Loading Kokoro model: ${Math.round(progress * 100)}%`);
}
};
// Initialize Kokoro
await this.kokoro.init({
progress
});
console.log('Kokoro initialized successfully');
// Mark as available
this.available = true;
// Set up voice based on preferences and locale
await this.setupVoiceFromPreferences();
// Report progress
if (progressCallback) {
progressCallback(100, "Kokoro TTS ready");
}
// Dispatch event
this.dispatchEvent('kokoro-loading-complete', { success: true });
this.loading = false;
return true;
} catch (directError) {
console.error('Error initializing Kokoro directly:', directError);
// Report progress
if (progressCallback) {
progressCallback(100, "Kokoro TTS failed to initialize");
}
// Dispatch event
this.dispatchEvent('kokoro-loading-complete', { success: false, error: directError });
this.loading = false;
return false;
}
}
} catch (error) {
console.error('Error initializing Kokoro:', error);
// Report progress
if (progressCallback) {
progressCallback(100, "Kokoro TTS failed to initialize");
}
// Dispatch event
this.dispatchEvent('kokoro-loading-complete', { success: false, error });
this.loading = false;
return false;
}
}
/**
* 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);
}
/**
* 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');
// Get current locale and preferred voice
let currentLocale = 'en-us';
let preferredVoice = '';
if (localization) {
currentLocale = localization.getLocale();
} else {
console.warn("Kokoro TTS: Localization module not found");
}
if (persistenceManager) {
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
} else {
console.warn("Kokoro TTS: Persistence Manager module not found");
}
// If we have a preferred voice, use it
if (preferredVoice) {
const success = this.setVoice(preferredVoice);
if (success) return true;
}
// Otherwise select based on locale
return this.selectVoiceForLocale(currentLocale);
} catch (error) {
console.error("Error setting up voice from preferences:", error);
return this.selectDefaultVoice();
}
}
/**
* 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.currentVoice = matchingVoice;
console.log(`Kokoro TTS: Selected voice ${matchingVoice.name} for locale ${locale}`);
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', matchingVoice.id);
}
return true;
}
/**
* Select a default voice
* @returns {boolean} - Success status
*/
selectDefaultVoice() {
if (this.voices.length === 0) {
console.warn("Kokoro 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.currentVoice = englishVoice;
console.log(`Kokoro TTS: Selected default English voice ${englishVoice.name}`);
} else {
// Otherwise use the first available voice
this.currentVoice = this.voices[0];
console.log(`Kokoro TTS: Selected first available voice ${this.voices[0].name}`);
}
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', this.currentVoice.id);
}
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 {
// Preprocess text
const processedText = this.preprocessText(text);
// Check if already cached
const cacheKey = `${this.currentVoice?.id || 'default'}_${processedText}`;
if (this.preloadCache.has(cacheKey)) {
return this.preloadCache.get(cacheKey);
}
// Generate speech
const result = await this.generateSpeech(processedText);
// Cache result
this.preloadCache.set(cacheKey, result);
return result;
} catch (error) {
console.error("Kokoro: 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) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
// Play the audio
if (preloadData.play) {
// Use the play function if available
preloadData.play();
// Call callback after audio duration
if (callback) {
setTimeout(() => {
callback({ success: true });
}, preloadData.buffer.duration * 1000);
}
} else if (preloadData.audio) {
// Set up event handlers
preloadData.audio.onended = () => {
if (callback) {
callback({ success: true });
}
};
preloadData.audio.onerror = (error) => {
console.error("Kokoro: Audio playback error:", error);
if (callback) {
callback({ success: false, reason: 'playback_error', error });
}
};
// Store reference to current audio
this.currentAudio = preloadData.audio;
// Play the audio
preloadData.audio.play();
} else {
console.error("Kokoro: Invalid preload data");
if (callback) {
setTimeout(() => callback({ success: false, reason: 'invalid_data' }), 0);
}
return false;
}
return true;
} catch (error) {
console.error("Kokoro: Error playing preloaded speech:", error);
if (callback) {
setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0);
}
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 || !text) return false;
try {
// Stop any current speech
this.stop();
// Preprocess text
const processedText = this.preprocessText(text);
// Generate speech
const result = await this.generateSpeech(processedText);
// Create audio element
const audio = new Audio();
// Set up event handlers
return new Promise((resolve) => {
audio.onended = () => {
this.currentAudio = null;
resolve(true);
};
audio.onerror = (error) => {
console.error("Kokoro TTS: Audio playback error:", error);
this.currentAudio = null;
resolve(false);
};
// Store reference to current audio
this.currentAudio = audio;
// Play the audio
if (result.play) {
// Use the play function if available
result.play();
// Resolve after duration
setTimeout(() => {
this.currentAudio = null;
resolve(true);
}, result.buffer.duration * 1000);
} else if (result.audio) {
// Play the audio element
result.audio.play().catch(error => {
console.error("Kokoro TTS: Failed to play audio:", error);
this.currentAudio = null;
resolve(false);
});
} else {
console.error("Kokoro TTS: Invalid result data");
this.currentAudio = null;
resolve(false);
}
});
} catch (error) {
console.error("Error speaking text with Kokoro TTS:", error);
return false;
}
}
/**
* Generate speech using the web worker or direct method
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Resolves with audio data
*/
async generateSpeech(text) {
if (this.worker) {
return new Promise((resolve, reject) => {
this.pendingGeneration = { text, resolve, reject };
// Send message to worker to generate speech
this.worker.postMessage({
type: 'generate',
text,
voice: this.currentVoice?.id || 'en_US-neural',
speed: this.options.rate
});
});
} else if (this.kokoro) {
// Fallback to direct method if worker is not available
return this.kokoro(text, {
voice: this.currentVoice?.id || 'en_US-neural',
speed: this.options.rate,
autoPlay: false
});
} else {
throw new Error('No Kokoro implementation available');
}
}
/**
* 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
* @returns {boolean} - Success status
*/
stop() {
if (!this.currentAudio) return true;
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudio = null;
return true;
} catch (error) {
console.error("Kokoro: Error stopping speech:", error);
return false;
}
}
/**
* Pause speaking
* @returns {boolean} - Success status
*/
pause() {
if (!this.currentAudio) return false;
try {
this.currentAudio.pause();
return true;
} catch (error) {
console.error("Kokoro: Error pausing speech:", error);
return false;
}
}
/**
* Resume speaking
* @returns {boolean} - Success status
*/
resume() {
if (!this.currentAudio) return false;
try {
this.currentAudio.play();
return true;
} catch (error) {
console.error("Kokoro: Error resuming speech:", error);
return false;
}
}
/**
* 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 {Array} - Array of voice objects
*/
getVoices() {
return this.voices.map(voice => ({
id: voice.id,
name: voice.name,
lang: voice.lang
}));
}
/**
* Set the voice by ID or name
* @param {string} voiceId - Voice ID or name
* @returns {boolean} - Success status
*/
setVoice(voiceId) {
if (!voiceId) return false;
const voice = this.voices.find(v => v.id === voiceId || v.name === voiceId);
if (!voice) {
console.warn(`Kokoro TTS: Voice '${voiceId}' not found`);
return false;
}
this.currentVoice = voice;
console.log(`Kokoro TTS: Set voice to ${voice.name}`);
// Update preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'voice', voice.id);
}
return true;
}
/**
* Set voice options
* @param {Object} options - Voice options
* @returns {boolean} - Success status
*/
setOptions(options = {}) {
if (!options) return false;
// Update options
if (typeof options.volume === 'number') this.options.volume = options.volume;
if (typeof options.rate === 'number') {
// Clamp rate between 0.5 and 2.0
this.options.rate = Math.max(0.5, Math.min(2.0, options.rate));
}
if (typeof options.pitch === 'number') this.options.pitch = options.pitch;
// Update preferences
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
if (typeof options.volume === 'number') {
persistenceManager.updatePreference('tts', 'volume', options.volume);
}
if (typeof options.rate === 'number') {
persistenceManager.updatePreference('tts', 'rate', options.rate);
}
}
return true;
}
}