537 lines
18 KiB
JavaScript
537 lines
18 KiB
JavaScript
/**
|
|
* BrowserTTSModule
|
|
* Provides TTS via Browser's Web Speech API
|
|
*/
|
|
import { TTSHandlerModule } from './tts-handler-module.js';
|
|
|
|
export class BrowserTTSModule extends TTSHandlerModule {
|
|
constructor() {
|
|
super('browser-tts', 'Browser TTS');
|
|
|
|
// Declare proper dependencies according to architecture principles
|
|
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
|
|
|
|
// Voice options
|
|
this.voiceOptions = {
|
|
voice: null, // Will be set during initialization
|
|
speed: 1.0,
|
|
pitch: 1.0,
|
|
volume: 1.0
|
|
};
|
|
|
|
// State variables
|
|
this.voices = [];
|
|
this.voicesByLang = {};
|
|
this.lastPreprocessedText = '';
|
|
this.isSpeaking = false;
|
|
this.currentUtterance = null;
|
|
|
|
// Bind additional methods
|
|
this.bindMethods(['handleVoicePreferenceChanged', 'estimateSpeechDuration']);
|
|
}
|
|
|
|
/**
|
|
* Initialize the Browser TTS module
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize() {
|
|
try {
|
|
this.reportProgress(10, 'Initializing Browser TTS');
|
|
|
|
// Initialize parent
|
|
const parentInit = await super.initialize();
|
|
if (!parentInit) {
|
|
console.error('Browser TTS: Parent initialization failed');
|
|
return false;
|
|
}
|
|
|
|
// Get dependencies using proper pattern
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (!persistenceManager) {
|
|
console.error('Browser TTS: Persistence Manager dependency not found');
|
|
return false;
|
|
}
|
|
|
|
const localization = this.getModule('localization');
|
|
if (!localization) {
|
|
console.error('Browser TTS: Localization dependency not found');
|
|
return false;
|
|
}
|
|
|
|
this.addEventListener(document, 'preference-updated', (event) => {
|
|
const { category, key } = event.detail || {};
|
|
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentUtterance) {
|
|
this.currentUtterance.volume = this.getPlaybackVolume();
|
|
}
|
|
});
|
|
|
|
// Check if browser supports speech synthesis
|
|
if (!window.speechSynthesis) {
|
|
console.error('Browser TTS: Speech synthesis not available in this browser');
|
|
return false;
|
|
}
|
|
|
|
// Load voices
|
|
this.reportProgress(30, 'Loading browser voices');
|
|
await this.loadVoices();
|
|
|
|
// Set up voice from preferences
|
|
this.reportProgress(70, 'Setting up voice preferences');
|
|
await this.setupVoiceFromPreferences();
|
|
|
|
// Set up event listeners
|
|
document.addEventListener('tts:browser:voicePreferenceChanged', this.handleVoicePreferenceChanged);
|
|
|
|
// Set up utterance handlers
|
|
this.setupUtteranceHandlers();
|
|
|
|
// Mark as ready
|
|
this.isReady = true;
|
|
|
|
this.reportProgress(100, 'Browser TTS initialization complete');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Browser TTS: Initialization error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load voices from browser speech synthesis API
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async loadVoices() {
|
|
// Helper function to process voices
|
|
const processVoices = () => {
|
|
// Get all voices from speechSynthesis
|
|
const synVoices = window.speechSynthesis.getVoices() || [];
|
|
|
|
if (synVoices.length === 0) {
|
|
console.warn('Browser TTS: No voices available');
|
|
return false;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
return true;
|
|
};
|
|
|
|
// 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 gameConfig = this.getModule('game-config');
|
|
|
|
if (!persistenceManager || this.voices.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
// Get preferred voice ID from preferences
|
|
const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', '');
|
|
|
|
// Get current locale
|
|
const currentLocale = gameConfig?.getLocale?.() || 'en_US';
|
|
|
|
// 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();
|
|
}
|
|
|
|
const normalizedLocale = String(locale).replace('_', '-').toLowerCase();
|
|
const langCode = normalizedLocale.split('-')[0];
|
|
|
|
// First try to find a voice that exactly matches the locale
|
|
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === normalizedLocale);
|
|
|
|
// 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();
|
|
}
|
|
|
|
getPlaybackVolume() {
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (!persistenceManager) {
|
|
return this.voiceOptions.volume || 1.0;
|
|
}
|
|
|
|
const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0);
|
|
const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0);
|
|
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
|
|
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
|
|
const configuredVolume = this.voiceOptions.volume || 1.0;
|
|
|
|
return Math.max(0, Math.min(1, configuredVolume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
document.dispatchEvent(new CustomEvent('tts:audio-started', {
|
|
detail: { provider: this.id || this.name }
|
|
}));
|
|
},
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preprocess text for TTS
|
|
* @param {string} text - Text to preprocess
|
|
* @returns {string} - Processed text
|
|
*/
|
|
preprocessText(text) {
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
// Remove HTML tags
|
|
let processed = text.replace(/<[^>]*>/g, ' ');
|
|
|
|
// 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 += '.';
|
|
}
|
|
|
|
this.lastPreprocessedText = processed;
|
|
return processed;
|
|
}
|
|
|
|
/**
|
|
* 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 || !text) {
|
|
if (callback) {
|
|
callback({ success: false, reason: 'not_ready_or_empty_text' });
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Stop any ongoing speech
|
|
this.stop();
|
|
|
|
// Process the text
|
|
const processedText = this.preprocessText(text);
|
|
|
|
// Create a new utterance
|
|
const utterance = new SpeechSynthesisUtterance(processedText);
|
|
|
|
// Set voice options
|
|
if (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.speed || 1.0;
|
|
utterance.pitch = this.voiceOptions.pitch || 1.0;
|
|
utterance.volume = this.getPlaybackVolume();
|
|
|
|
// Set up event handlers
|
|
utterance.onstart = this.utteranceHandlers.start;
|
|
utterance.onpause = this.utteranceHandlers.pause;
|
|
utterance.onresume = this.utteranceHandlers.resume;
|
|
|
|
// Start speaking
|
|
this.currentUtterance = utterance;
|
|
|
|
return new Promise(resolve => {
|
|
utterance.onend = () => {
|
|
this.utteranceHandlers.end();
|
|
if (callback) {
|
|
callback({ success: true });
|
|
}
|
|
resolve(true);
|
|
};
|
|
utterance.onerror = (event) => {
|
|
this.utteranceHandlers.error(event);
|
|
if (callback) {
|
|
callback({ success: false, reason: 'synthesis_error', error: event });
|
|
}
|
|
resolve(false);
|
|
};
|
|
speechSynthesis.speak(utterance);
|
|
});
|
|
} catch (error) {
|
|
console.error('Browser TTS: Failed to speak:', error);
|
|
if (callback) {
|
|
callback({ success: false, reason: 'speak_error', error });
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
stop() {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available voices
|
|
* @returns {Array} - Array of voice objects
|
|
*/
|
|
getAvailableVoices() {
|
|
return this.voices;
|
|
}
|
|
|
|
/**
|
|
* Set voice options
|
|
* @param {Object} options - Voice options
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (typeof options.speed === 'number') {
|
|
// Web Speech rate uses 1.0 as normal, matching the app-wide slider.
|
|
this.voiceOptions.speed = Math.max(0.5, Math.min(2.0, options.speed));
|
|
}
|
|
|
|
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) {
|
|
if (!this.isReady || !text) {
|
|
return { success: false, reason: 'not_ready_or_empty_text' };
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
text,
|
|
duration: this.estimateSpeechDuration(text),
|
|
directPlayback: true
|
|
};
|
|
}
|
|
|
|
estimateSpeechDuration(text) {
|
|
const processedText = this.preprocessText(text);
|
|
const charactersPerSecond = 12;
|
|
const speed = Math.max(0.5, Math.min(2.0, Number(this.voiceOptions.speed) || 1.0));
|
|
return Math.max((processedText.length / (charactersPerSecond * speed)) * 1000, 800);
|
|
}
|
|
|
|
/**
|
|
* 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 (typeof callback === 'function') {
|
|
callback({ success: false, reason: 'not_supported' });
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const browserTTSModule = new BrowserTTSModule();
|
|
|
|
export { browserTTSModule };
|