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

499 lines
16 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'];
// 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']);
}
/**
* 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;
}
// 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 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);
}
}
/**
* 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.voiceOptions.volume || 1.0;
// Set up event handlers
utterance.onstart = this.utteranceHandlers.start;
utterance.onend = () => {
this.utteranceHandlers.end();
if (callback) {
callback({ success: true });
}
};
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
this.currentUtterance = utterance;
speechSynthesis.speak(utterance);
return true;
} 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') {
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);
}
}
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;
}
}
const browserTTSModule = new BrowserTTSModule();
export { browserTTSModule };