Refactored modules and updated loader.

This commit is contained in:
2025-04-06 18:35:04 +00:00
parent fc693ae695
commit 0ab639fd25
37 changed files with 3530 additions and 5989 deletions
+364 -436
View File
@@ -1,60 +1,43 @@
/**
* BrowserTTSModule for AI Interactive Fiction
* Implementation using the browser's Web Speech API
* BrowserTTSModule
* Provides TTS via Browser's Web Speech API
*/
import { TTSHandlerModule } from './tts-handler-module.js';
/**
* Browser TTS Module - Uses the browser's Web Speech API for TTS
*/
export class BrowserTTSModule extends TTSHandlerModule {
constructor() {
super('browser', 'Browser TTS');
super('browser-tts', 'Browser TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
// Voice options
this.voiceOptions = {
voice: null, // Will be set during initialization
rate: 1.0,
voice: null, // Will be set during initialization
speed: 1.0,
pitch: 1.0,
volume: 1.0
};
// State
this.available = false;
// State variables
this.voices = [];
this.voicesByLang = {};
this.lastPreprocessedText = '';
this.isSpeaking = false;
this.currentUtterance = null;
// Ensure dependencies are correctly defined from parent class
// this.dependencies should already contain ['persistence-manager', 'localization']
// Bind additional methods beyond those in TTSHandlerModule
this.bindMethods([
'onVoicesChanged',
'loadVoices',
'selectVoiceForLocale',
'synthesizeToWav',
'speakPreloaded',
'speak',
'preprocessText',
'inferVoiceGender'
]);
// Bind additional methods
this.bindMethods(['onVoicesChanged', 'handleVoicePreferenceChanged']);
}
/**
* Initialize the browser TTS module
* Initialize the Browser TTS module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
this.reportProgress(10, 'Initializing Browser TTS');
// Check for browser support
if (!window.speechSynthesis) {
console.error('Browser TTS: Speech synthesis not available in this browser');
return false;
}
this.reportProgress(30, 'Browser TTS supported');
// Initialize parent
const parentInit = await super.initialize();
if (!parentInit) {
@@ -62,201 +45,264 @@ export class BrowserTTSModule extends TTSHandlerModule {
return false;
}
// Get required dependencies
// Get dependencies using proper pattern
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
console.error('Browser TTS: Required dependency persistence-manager not found');
console.error('Browser TTS: Persistence Manager dependency not found');
return false;
}
const localization = this.getModule('localization');
if (!localization) {
console.error('Browser TTS: Required dependency localization not found');
console.error('Browser TTS: Localization dependency not found');
return false;
}
// Check if browser supports speech synthesis
if (!window.speechSynthesis) {
console.error('Browser TTS: Speech synthesis not available in this browser');
return false;
}
// Load voices
const voicesLoaded = await this.loadVoices();
if (!voicesLoaded) {
console.error('Browser TTS: Failed to load voices');
return false;
}
// Set speech options from preferences
this.voiceOptions.rate = persistenceManager.getPreference('tts', 'rate', 1.0);
this.voiceOptions.pitch = persistenceManager.getPreference('tts', 'pitch', 1.0);
this.voiceOptions.volume = persistenceManager.getPreference('tts', 'volume', 1.0);
const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', '');
this.reportProgress(30, 'Loading browser voices');
await this.loadVoices();
// Set voice based on current locale
const currentLocale = localization.getLocale() || 'en-us';
await this.selectVoiceForLocale(currentLocale, preferredVoice);
// Set up voice from preferences
this.reportProgress(70, 'Setting up voice preferences');
await this.setupVoiceFromPreferences();
// Listen for locale changes
document.addEventListener('locale:changed', async (event) => {
if (event.detail && event.detail.locale) {
await this.selectVoiceForLocale(event.detail.locale);
}
});
// Set up event listeners
document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged);
// Listen for voices changed events
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = this.onVoicesChanged;
}
// Set up utterance handlers
this.setupUtteranceHandlers();
// Mark as ready
this.isReady = true;
this.available = true;
this.reportProgress(100, 'Browser TTS initialized');
this.reportProgress(100, 'Browser TTS initialization complete');
return true;
} catch (error) {
console.error('Browser TTS: Initialization error:', error);
this.isReady = false;
this.available = false;
return false;
}
}
/**
* Handle voices changed event
*/
async onVoicesChanged() {
await this.loadVoices();
// Re-select voice based on current locale
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
if (localization && persistenceManager) {
const currentLocale = localization.getLocale() || 'en-us';
const preferredVoice = persistenceManager.getPreference('tts', 'browser_voice', '');
await this.selectVoiceForLocale(currentLocale, preferredVoice);
}
}
/**
* Load available voices from the speech synthesis API
* Load voices from browser speech synthesis API
* @returns {Promise<boolean>} - Resolves with success status
*/
async loadVoices() {
try {
this.reportProgress(40, 'Loading browser voices');
// Helper function to process voices
const processVoices = () => {
// Get all voices from speechSynthesis
const synVoices = window.speechSynthesis.getVoices() || [];
// Try to get voices
let voices = window.speechSynthesis.getVoices();
if (synVoices.length === 0) {
console.warn('Browser TTS: No voices available');
return false;
}
// If voices array is empty, wait for onvoiceschanged event
if (!voices || voices.length === 0) {
try {
console.log('Browser TTS: No voices available immediately, waiting for voices to load...');
// Wait for voices to be loaded (with timeout)
voices = await new Promise((resolve, reject) => {
// Set a timeout in case voices never load
const timeout = setTimeout(() => {
console.warn('Browser TTS: Timeout waiting for voices');
// Resolve with empty array instead of rejecting
resolve([]);
}, 3000);
// Listen for voices changed event
window.speechSynthesis.onvoiceschanged = () => {
clearTimeout(timeout);
const loadedVoices = window.speechSynthesis.getVoices();
console.log(`Browser TTS: Voices loaded, found ${loadedVoices.length} voices`);
resolve(loadedVoices);
};
});
} catch (voiceWaitError) {
console.error('Browser TTS: Error waiting for voices:', voiceWaitError);
// Continue with empty voices array
voices = [];
// Transform to our format
this.voices = synVoices.map((voice, index) => ({
id: voice.voiceURI || `voice-${index}`,
name: voice.name,
language: voice.lang,
localService: voice.localService,
default: voice.default,
original: voice // Keep reference to original voice
}));
// Group voices by language
this.voicesByLang = {};
this.voices.forEach(voice => {
if (voice.language) {
const langCode = voice.language.split('-')[0].toLowerCase();
if (!this.voicesByLang[langCode]) {
this.voicesByLang[langCode] = [];
}
this.voicesByLang[langCode].push(voice);
}
}
});
// Store voices
this.voices = voices || [];
// Log available voices for debugging
console.log(`Browser TTS: Loaded ${this.voices.length} voices`);
if (this.voices.length > 0) {
console.log('Browser TTS: First few voices:', this.voices.slice(0, 3));
}
// If no voices available but speech synthesis is supported, still return true
// Some browsers may not expose voices but still support speech synthesis
if (this.voices.length === 0) {
console.warn('Browser TTS: No voices available, but continuing with default voice');
// Create a default voice entry
this.voices = [{
default: true,
lang: 'en-US',
localService: true,
name: 'Default Voice',
voiceURI: 'default'
}];
}
this.reportProgress(60, 'Browser voices loaded');
return true;
} catch (error) {
console.error('Browser TTS: Error loading voices:', error);
};
// If voices are already loaded, process them
if (window.speechSynthesis.getVoices().length > 0) {
return processVoices();
}
// Otherwise, wait for voiceschanged event
return new Promise(resolve => {
// Set up timeout to handle browsers that don't trigger voiceschanged
const timeoutId = setTimeout(() => {
if (window.speechSynthesis.getVoices().length > 0) {
window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged);
resolve(processVoices());
} else {
console.warn('Browser TTS: Voices not loaded after timeout');
resolve(false);
}
}, 1000);
this.onVoicesChanged = () => {
clearTimeout(timeoutId);
window.speechSynthesis.removeEventListener('voiceschanged', this.onVoicesChanged);
resolve(processVoices());
};
window.speechSynthesis.addEventListener('voiceschanged', this.onVoicesChanged);
});
}
/**
* 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 || this.voices.length === 0) {
return false;
}
// Get preferred voice ID from preferences
const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', '');
// Get current locale
const currentLocale = localization.getLocale();
// If we have a preferred voice ID, use it
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
this.voiceOptions.voice = preferredVoiceId;
return true;
}
// Otherwise, select voice based on locale
if (currentLocale) {
return this.selectVoiceForLocale(currentLocale);
}
// Fall back to default voice
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();
}
// Extract language code from locale (e.g., 'en-US' -> 'en')
const langCode = locale.split('-')[0].toLowerCase();
// First try to find a voice that exactly matches the locale
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase());
// If not found, try to find a voice for the language
if (!matchedVoice && this.voicesByLang[langCode]) {
// Prefer default voices if available
matchedVoice = this.voicesByLang[langCode].find(v => v.default) || this.voicesByLang[langCode][0];
}
if (matchedVoice) {
this.voiceOptions.voice = matchedVoice.id;
return true;
}
// Fall back to default voice
return this.selectDefaultVoice();
}
/**
* Select a default voice
* @returns {boolean} - Success status
*/
selectDefaultVoice() {
if (this.voices.length === 0) {
return false;
}
// Find a default English voice if available
const defaultEnVoice = this.voices.find(v => v.default && v.language && v.language.startsWith('en'));
// Otherwise use any default voice
const defaultVoice = defaultEnVoice || this.voices.find(v => v.default) || this.voices[0];
this.voiceOptions.voice = defaultVoice.id;
return true;
}
/**
* Set up utterance handlers for speech events
*/
setupUtteranceHandlers() {
// Handler functions for utterance events
this.utteranceHandlers = {
start: () => {
this.isSpeaking = true;
},
end: () => {
this.isSpeaking = false;
this.currentUtterance = null;
},
error: (event) => {
console.error('Browser TTS: Speech error:', event);
this.isSpeaking = false;
this.currentUtterance = null;
},
pause: () => {
this.isSpeaking = false;
},
resume: () => {
this.isSpeaking = true;
}
};
}
/**
* Handle voice preference changed event
* @param {Event} event - Event object
*/
handleVoicePreferenceChanged(event) {
if (event && event.detail) {
this.setVoiceOptions(event.detail);
}
}
/**
* Set voice based on locale
* @param {string} locale - Locale code (e.g., 'en-us', 'de', 'fr')
* @param {string} preferredVoice - Optional preferred voice name
* @returns {Promise<boolean>} - Success status
* Preprocess text for TTS
* @param {string} text - Text to preprocess
* @returns {string} - Processed text
*/
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
// Normalize locale format
locale = locale.toLowerCase().replace('_', '-');
const languageCode = locale.split('-')[0];
// First try to use the preferred voice if specified
if (preferredVoice) {
const voice = this.voices.find(v =>
v.name === preferredVoice ||
v.voiceURI === preferredVoice
);
if (voice) {
this.voiceOptions.voice = voice;
return true;
}
preprocessText(text) {
if (!text) {
return '';
}
// Try to find a voice that matches the exact locale
const exactMatch = this.voices.find(v =>
v.lang.toLowerCase() === locale
);
// Remove HTML tags
let processed = text.replace(/<[^>]*>/g, ' ');
if (exactMatch) {
this.voiceOptions.voice = exactMatch;
return true;
// 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 += '.';
}
// Try to find a voice that matches the language code
const languageMatch = this.voices.find(v =>
v.lang.toLowerCase().startsWith(languageCode)
);
if (languageMatch) {
this.voiceOptions.voice = languageMatch;
return true;
}
// Fallback to the first available voice
if (this.voices.length > 0) {
this.voiceOptions.voice = this.voices[0];
return true;
}
// No voices available
return false;
this.lastPreprocessedText = processed;
return processed;
}
/**
@@ -266,210 +312,64 @@ export class BrowserTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status
*/
speak(text, callback = null) {
if (!this.isReady || !window.speechSynthesis) {
if (!this.isReady || !text) {
if (callback) {
callback({ success: false, reason: 'not_ready' });
callback({ success: false, reason: 'not_ready_or_empty_text' });
}
return false;
}
// Stop any ongoing speech
this.stop();
const processedText = this.preprocessText(text);
// Create utterance
const utterance = new SpeechSynthesisUtterance(processedText);
// Set options
if (this.voiceOptions.voice) {
utterance.voice = this.voiceOptions.voice;
}
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
// Set up event handlers
utterance.onend = () => {
this.isSpeaking = false;
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
this.isSpeaking = false;
console.error('Browser TTS: Speech error', error);
if (callback) {
callback({ success: false, reason: 'synthesis_error', error });
}
};
// Store current utterance
this.currentUtterance = utterance;
this.isSpeaking = true;
// Start speaking
window.speechSynthesis.speak(utterance);
return true;
}
/**
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
if (!this.isReady || !window.speechSynthesis) {
return { success: false, reason: 'not_ready' };
}
// Generate WAV audio data
const wavResult = await this.synthesizeToWav(text);
if (!wavResult.success) {
return { success: false, reason: 'synthesis_failed' };
}
return {
success: true,
audioData: wavResult.audioData,
text,
duration: wavResult.duration || 0
};
}
/**
* Convert speech synthesis to WAV format
* @param {string} text - Text to synthesize
* @returns {Promise<Object>} - Object with audio data
*/
async synthesizeToWav(text) {
return new Promise((resolve) => {
if (!this.isReady || !window.speechSynthesis) {
resolve({ success: false, reason: 'not_ready' });
return;
}
try {
// Stop any ongoing speech
this.stop();
// Process text for better synthesis
// Process the text
const processedText = this.preprocessText(text);
// Create audio context
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) {
resolve({ success: false, reason: 'no_audio_context' });
return;
}
const audioContext = new AudioContext();
// Create media stream destination
const destination = audioContext.createMediaStreamDestination();
// Create media recorder
const mediaRecorder = new MediaRecorder(destination.stream);
const audioChunks = [];
// Set up event handlers
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
// Create blob from chunks
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
// Convert blob to array buffer
const reader = new FileReader();
reader.onloadend = () => {
resolve({
success: true,
audioData: reader.result
});
};
reader.onerror = () => {
resolve({ success: false, reason: 'blob_read_error' });
};
reader.readAsArrayBuffer(audioBlob);
};
// Create utterance
// Create a new utterance
const utterance = new SpeechSynthesisUtterance(processedText);
// Set options
// Set voice options
if (this.voiceOptions.voice) {
utterance.voice = this.voiceOptions.voice;
const voice = this.voices.find(v => v.id === this.voiceOptions.voice);
if (voice && voice.original) {
utterance.voice = voice.original;
}
}
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
utterance.rate = this.voiceOptions.speed || 1.0;
utterance.pitch = this.voiceOptions.pitch || 1.0;
utterance.volume = this.voiceOptions.volume || 1.0;
// Start recording
mediaRecorder.start();
// Set up completion handling
// Set up event handlers
utterance.onstart = this.utteranceHandlers.start;
utterance.onend = () => {
mediaRecorder.stop();
this.utteranceHandlers.end();
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
console.error('Browser TTS: Synthesis error', error);
mediaRecorder.stop();
resolve({ success: false, reason: 'synthesis_error' });
utterance.onerror = (event) => {
this.utteranceHandlers.error(event);
if (callback) {
callback({ success: false, reason: 'synthesis_error', error: event });
}
};
utterance.onpause = this.utteranceHandlers.pause;
utterance.onresume = this.utteranceHandlers.resume;
// Start speaking
window.speechSynthesis.speak(utterance);
this.currentUtterance = utterance;
speechSynthesis.speak(utterance);
// Set timeout in case onend never fires
setTimeout(() => {
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
}, 30000); // 30-second timeout
});
}
/**
* Speak preloaded audio data
* @param {Object} preloadedData - Data from preloadSpeech
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
speakPreloaded(preloadedData, callback = null) {
if (!preloadedData || !preloadedData.text) {
console.error('Browser TTS: Invalid preloaded data');
return true;
} catch (error) {
console.error('Browser TTS: Failed to speak:', error);
if (callback) {
callback({ success: false, reason: 'speak_error', error });
}
return false;
}
// For browser TTS, we don't use the preloaded data directly
// Instead, we just speak the text again
return this.speak(preloadedData.text, callback);
}
/**
* Preprocess text for TTS
* @param {string} text - Text to preprocess
* @returns {string} - Processed text
*/
preprocessText(text) {
// Remove HTML tags
text = text.replace(/<[^>]*>/g, ' ');
// Replace special characters with their spoken equivalents
text = text.replace(/&/g, ' and ');
// Normalize whitespace
text = text.replace(/\s+/g, ' ').trim();
return text;
}
/**
@@ -477,94 +377,122 @@ export class BrowserTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status
*/
stop() {
if (window.speechSynthesis) {
window.speechSynthesis.cancel();
try {
speechSynthesis.cancel();
this.isSpeaking = false;
this.currentUtterance = null;
return true;
} catch (error) {
console.error('Browser TTS: Failed to stop speech:', error);
return false;
}
}
/**
* Pause speaking
* @returns {boolean} - Success status
*/
pause() {
try {
if (this.isSpeaking) {
speechSynthesis.pause();
return true;
}
return false;
} catch (error) {
console.error('Browser TTS: Failed to pause speech:', error);
return false;
}
}
/**
* Resume speaking
* @returns {boolean} - Success status
*/
resume() {
try {
speechSynthesis.resume();
return true;
} catch (error) {
console.error('Browser TTS: Failed to resume speech:', error);
return false;
}
return false;
}
/**
* Get available voices
* @returns {Array} - Array of voice objects
*/
async getVoices() {
if (!this.isReady) {
return [];
}
const localization = this.getModule('localization');
const currentLocale = localization ? localization.getLocale() : 'en-us';
// Normalize locale format
const normalizedLocale = currentLocale.toLowerCase().replace('_', '-');
const languageCode = normalizedLocale.split('-')[0];
// Filter voices by current locale
const filteredVoices = this.voices.filter(voice => {
const voiceLang = voice.lang.toLowerCase();
return voiceLang.startsWith(languageCode) ||
voiceLang === normalizedLocale ||
(normalizedLocale.startsWith(voiceLang) && voiceLang.length === 2);
});
// If matching voices found, use them
if (filteredVoices.length > 0) {
return filteredVoices.map(voice => ({
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
gender: this.inferVoiceGender(voice.name)
}));
}
// If no matching voices found, return all voices
return this.voices.map(voice => ({
id: voice.voiceURI,
name: voice.name,
lang: voice.lang,
gender: this.inferVoiceGender(voice.name)
}));
getAvailableVoices() {
return this.voices;
}
/**
* Infer voice gender from name
* @param {string} name - Voice name
* @returns {string} - Inferred gender ('male', 'female', or 'unknown')
* Set voice options
* @param {Object} options - Voice options
*/
inferVoiceGender(name) {
const lowerName = name.toLowerCase();
// Common terms indicating gender
const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir'];
const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss'];
// Check for explicit gender terms in the name
for (const term of maleTerms) {
if (lowerName.includes(term)) return 'male';
setVoiceOptions(options = {}) {
if (options.voice) {
this.voiceOptions.voice = options.voice;
// Save voice preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'browser_voice', options.voice);
}
}
for (const term of femaleTerms) {
if (lowerName.includes(term)) return 'female';
if (typeof options.speed === 'number') {
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
// Save speed preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'browser_speed', options.speed);
}
}
return 'unknown';
if (typeof options.pitch === 'number') {
this.voiceOptions.pitch = Math.max(0.5, Math.min(2.0, options.pitch));
// Save pitch preference
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'browser_pitch', options.pitch);
}
}
if (typeof options.volume === 'number') {
this.voiceOptions.volume = Math.max(0, Math.min(1.0, options.volume));
}
}
/**
* Preload speech for later playback
* Not applicable for the browser TTS (always returns null)
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Promise that resolves to null
*/
async preloadSpeech(text) {
// Browser TTS can't preload speech
return { success: false, reason: 'not_supported' };
}
/**
* Speak preloaded speech
* Not applicable for the browser TTS (always returns false)
* @param {Object} preloadData - Preloaded speech data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status (always false)
*/
speakPreloaded(preloadData, callback = null) {
if (callback) {
callback({ success: false, reason: 'not_supported' });
}
return false;
}
}
// Register the module with the module registry
// Module registry MUST be accessed via window, not direct import
if (window.moduleRegistry) {
try {
// Create instance first, then register it
const browserTTSModule = new BrowserTTSModule();
window.moduleRegistry.register(browserTTSModule);
console.log('Browser TTS Module registered successfully');
} catch (err) {
console.error('Failed to register Browser TTS Module:', err);
}
} else {
console.error('Module registry not available when attempting to register Browser TTS Module');
}
const browserTTSModule = new BrowserTTSModule();
export { browserTTSModule };