Files
ai.interactive.fiction/public/js/browser-tts-handler.js
T

590 lines
20 KiB
JavaScript

/**
* BrowserTTSHandler for AI Interactive Fiction
* Implementation using the browser's Web Speech API
*/
import { TTSHandler } from './tts-handler.js';
import { moduleRegistry } from './module-registry.js';
export class BrowserTTSHandler extends TTSHandler {
constructor() {
super();
this.id = 'browser';
this.name = 'Browser TTS Handler';
// Voice options
this.voiceOptions = {
voice: null, // Will be set during initialization
rate: 1.0,
pitch: 1.0,
volume: 1.0
};
// State
this.available = false;
this.voices = [];
this.currentUtterance = null;
this.preloadCache = new Map();
// Add dependencies
this.dependencies = ['localization', 'persistence-manager'];
// Bind methods
this.bindMethods([
'initialize',
'speak',
'speakPreloaded',
'preloadSpeech',
'stop',
'isAvailable',
'getId',
'getVoices',
'setVoiceOptions',
'onVoicesChanged',
'getModule'
]);
}
/**
* 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);
}
/**
* Initialize the browser TTS handler
* @param {Function} progressCallback - Callback for progress updates
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize(progressCallback = null) {
try {
if (progressCallback) {
progressCallback(10, "Initializing Browser TTS Handler");
}
// Check if the browser supports speech synthesis
if (!window.speechSynthesis) {
console.error("Browser TTS: Speech synthesis not supported by browser");
if (progressCallback) {
progressCallback(100, "Browser TTS unavailable");
}
return false;
}
if (progressCallback) {
progressCallback(30, "Loading voices");
}
try {
// Load available voices
await this.loadVoices();
if (progressCallback) {
progressCallback(70, "Setting up voice");
}
// Get localization module
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.error("Browser TTS: Localization module not found");
}
if (persistenceManager) {
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
} else {
console.error("Browser TTS: Persistence Manager module not found");
}
// Set voice based on locale and preferences
await this.selectVoiceForLocale(currentLocale, preferredVoice);
// Check if we have a voice set
if (this.voiceOptions.voice) {
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "Browser TTS Handler ready");
}
return true;
} else {
// Try one more time with a delay
console.log("Browser TTS: No voice set, trying again after delay");
if (progressCallback) {
progressCallback(80, "Retrying voice loading");
}
// Wait a bit and try again
return new Promise(resolve => {
setTimeout(async () => {
await this.loadVoices();
await this.selectVoiceForLocale(currentLocale, preferredVoice);
if (this.voiceOptions.voice) {
this.available = true;
this.isReady = true;
if (progressCallback) {
progressCallback(100, "Browser TTS Handler ready");
}
resolve(true);
} else {
console.error("Browser TTS: Failed to set voice after retry");
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
resolve(false);
}
}, 1000);
});
}
} catch (error) {
console.error("Browser TTS: Error loading voices:", error);
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
return false;
}
} catch (error) {
console.error("Browser TTS: Initialization error:", error);
if (progressCallback) {
progressCallback(100, "Browser TTS initialization failed");
}
return false;
}
}
/**
* Handle voices changed event
*/
async onVoicesChanged() {
await this.loadVoices();
const localization = this.getModule('localization');
const persistenceManager = this.getModule('persistence-manager');
let currentLocale = 'en-us';
let preferredVoice = '';
if (localization) {
currentLocale = localization.getLocale();
}
if (persistenceManager) {
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
}
await this.selectVoiceForLocale(currentLocale, preferredVoice);
}
/**
* Load available voices
* @returns {Promise<void>}
*/
async loadVoices() {
return new Promise(resolve => {
// Get available voices
const getVoices = () => {
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices`);
resolve();
};
// Some browsers need a timeout to get voices
const timeoutId = setTimeout(() => {
if (this.voices.length === 0) {
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices after timeout`);
resolve();
}
}, 1000);
// Try to get voices immediately
this.voices = speechSynthesis.getVoices() || [];
if (this.voices.length > 0) {
clearTimeout(timeoutId);
console.log(`Browser TTS: Loaded ${this.voices.length} voices immediately`);
resolve();
} else {
// If no voices are available yet, set up the onvoiceschanged event
speechSynthesis.onvoiceschanged = () => {
clearTimeout(timeoutId);
this.voices = speechSynthesis.getVoices() || [];
console.log(`Browser TTS: Loaded ${this.voices.length} voices from event`);
speechSynthesis.onvoiceschanged = null;
resolve();
};
}
});
}
/**
* 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<void>}
*/
async selectVoiceForLocale(locale = 'en-us', preferredVoice = '') {
// Normalize locale for comparison
const normalizedLocale = locale.toLowerCase().split('-')[0];
// If we have a preferred voice, try to use it first
if (preferredVoice) {
const matchingVoice = this.voices.find(voice =>
voice.name === preferredVoice ||
voice.voiceURI === preferredVoice
);
if (matchingVoice) {
this.voiceOptions.voice = matchingVoice;
console.log(`Browser TTS: Using preferred voice: ${matchingVoice.name}`);
return;
}
}
// Find voices matching the locale
const localeVoices = this.voices.filter(voice => {
const voiceLocale = voice.lang.toLowerCase();
return voiceLocale.startsWith(normalizedLocale) ||
voice.name.toLowerCase().includes(normalizedLocale);
});
if (localeVoices.length > 0) {
// Use the first matching voice
this.voiceOptions.voice = localeVoices[0];
console.log(`Browser TTS: Using ${normalizedLocale} voice: ${this.voiceOptions.voice.name}`);
return;
}
// If no matching voice found, try to find any voice
if (this.voices.length > 0) {
// Look for a preferred language voice (English)
const englishVoices = this.voices.filter(voice =>
voice.lang.toLowerCase().startsWith('en')
);
if (englishVoices.length > 0) {
this.voiceOptions.voice = englishVoices[0];
console.log(`Browser TTS: No ${normalizedLocale} voice found, using English voice: ${this.voiceOptions.voice.name}`);
} else {
// Use the first available voice
this.voiceOptions.voice = this.voices[0];
console.log(`Browser TTS: No ${normalizedLocale} or English voice found, using: ${this.voiceOptions.voice.name}`);
}
} else {
console.log("Browser TTS: No voices available");
}
}
/**
* Preload speech for a text
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
if (!this.available || !text || !this.voiceOptions.voice) {
return null;
}
try {
// Process text for TTS
const processedText = this.preprocessText(text);
console.log(`Browser TTS: Preloading speech for: "${processedText.substring(0, 50)}${processedText.length > 50 ? '...' : ''}"`);
// Create utterance but don't speak it yet
const utterance = new SpeechSynthesisUtterance(processedText);
// Set voice and options
utterance.voice = this.voiceOptions.voice;
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
utterance.lang = this.voiceOptions.voice.lang;
// Store preloaded data
const preloadData = {
utterance,
text: processedText
};
this.preloadCache.set(text, preloadData);
return preloadData;
} catch (error) {
console.warn("Browser TTS: Error preloading speech:", error);
return null;
}
}
/**
* Speak text using preloaded utterance
* @param {Object} preloadData - Preloaded speech data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status
*/
speakPreloaded(preloadData, callback = null) {
if (!this.available || !preloadData || !preloadData.utterance) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'no_preloaded_data' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
const { utterance, text } = preloadData;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text });
// Set up event listeners
utterance.onend = () => {
this.currentUtterance = null;
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text });
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
this.currentUtterance = null;
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.error || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'synthesis_error', error });
}
};
// Store reference to current utterance
this.currentUtterance = utterance;
// Speak the utterance
speechSynthesis.speak(utterance);
return true;
} catch (error) {
console.error("Browser TTS: Error playing preloaded speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: preloadData.text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
}
return false;
}
}
/**
* 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.available || !this.voiceOptions.voice) {
if (callback) {
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
}
return false;
}
try {
// Stop any current speech
this.stop();
// Check if we have this in the preload cache
if (this.preloadCache.has(text)) {
const preloadData = this.preloadCache.get(text);
this.preloadCache.delete(text); // Remove from cache
return this.speakPreloaded(preloadData, callback);
}
// Process text for TTS
const processedText = this.preprocessText(text);
// Create utterance
const utterance = new SpeechSynthesisUtterance(processedText);
// Set voice and options
utterance.voice = this.voiceOptions.voice;
utterance.rate = this.voiceOptions.rate;
utterance.pitch = this.voiceOptions.pitch;
utterance.volume = this.voiceOptions.volume;
utterance.lang = this.voiceOptions.voice.lang;
// Dispatch start event
this.dispatchEvent('tts:speak:start', { text: processedText });
// Set up event listeners
utterance.onend = () => {
this.currentUtterance = null;
// Dispatch end event
this.dispatchEvent('tts:speak:end', { text: processedText });
if (callback) {
callback({ success: true });
}
};
utterance.onerror = (error) => {
this.currentUtterance = null;
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text: processedText,
error: error.error || 'Unknown error'
});
if (callback) {
callback({ success: false, reason: 'synthesis_error', error });
}
};
// Store reference to current utterance
this.currentUtterance = utterance;
// Speak the utterance
speechSynthesis.speak(utterance);
return true;
} catch (error) {
console.error("Browser TTS: Error generating speech:", error);
// Dispatch error event
this.dispatchEvent('tts:speak:error', {
text,
error: error.message || 'Unknown error'
});
if (callback) {
setTimeout(() => callback({ success: false, reason: 'synthesis_error', error }), 0);
}
return false;
}
}
/**
* 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
*/
stop() {
if (speechSynthesis) {
speechSynthesis.cancel();
this.currentUtterance = null;
}
}
/**
* Check if TTS is available
* @returns {boolean} - True if TTS is available
*/
isAvailable() {
return this.available && this.voiceOptions.voice !== null;
}
/**
* 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.voiceURI,
name: voice.name,
language: voice.lang
}));
}
/**
* Set voice options
* @param {Object} options - Voice options
*/
setVoiceOptions(options = {}) {
if (options.voice) {
// Find the voice by ID or name
const voice = this.voices.find(v =>
v.voiceURI === options.voice ||
v.name === options.voice
);
if (voice) {
this.voiceOptions.voice = voice;
}
}
if (typeof options.rate === 'number') {
// Clamp rate between 0.1 and 10
this.voiceOptions.rate = Math.max(0.1, Math.min(10, options.rate));
}
if (typeof options.pitch === 'number') {
// Clamp pitch between 0 and 2
this.voiceOptions.pitch = Math.max(0, Math.min(2, options.pitch));
}
if (typeof options.volume === 'number') {
// Clamp volume between 0 and 1
this.voiceOptions.volume = Math.max(0, Math.min(1, options.volume));
}
}
}