Refactored modules and updated loader.
This commit is contained in:
+364
-436
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user