644 lines
22 KiB
JavaScript
644 lines
22 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() {
|
|
// Get localization module for current locale
|
|
const localization = this.getModule('localization');
|
|
let currentLocale = localization ? localization.getLocale().toLowerCase() : 'en-us';
|
|
|
|
// Create language code variations for matching
|
|
const languageCode = currentLocale.split('-')[0]; // e.g., 'en' from 'en-us'
|
|
|
|
// Filter voices by current locale
|
|
const filteredVoices = this.voices.filter(voice => {
|
|
const voiceLang = voice.lang.toLowerCase();
|
|
return voiceLang.startsWith(languageCode) ||
|
|
voiceLang === currentLocale ||
|
|
// For handling cases like 'en' matching 'en-us'
|
|
(currentLocale.startsWith(voiceLang) && voiceLang.length === 2);
|
|
});
|
|
|
|
// If no matching voices found, fall back to all voices
|
|
const voicesToUse = filteredVoices.length > 0 ? filteredVoices : this.voices;
|
|
|
|
return voicesToUse.map(voice => ({
|
|
id: voice.voiceURI,
|
|
name: voice.name,
|
|
lang: voice.lang,
|
|
// Add proper gender field if available, otherwise infer from name
|
|
gender: this.inferVoiceGender(voice.name)
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Infer voice gender from name
|
|
* @param {string} name - Voice name
|
|
* @returns {string} - Inferred gender ('male', 'female', or 'unknown')
|
|
*/
|
|
inferVoiceGender(name) {
|
|
const lowerName = name.toLowerCase();
|
|
|
|
// Common terms indicating gender
|
|
const maleTerms = ['male', 'man', 'guy', 'boy', 'mr', 'sir', 'him', 'his'];
|
|
const femaleTerms = ['female', 'woman', 'lady', 'girl', 'ms', 'mrs', 'miss', 'her', 'hers'];
|
|
|
|
// Check for explicit gender terms in the name
|
|
for (const term of maleTerms) {
|
|
if (lowerName.includes(term)) return 'male';
|
|
}
|
|
|
|
for (const term of femaleTerms) {
|
|
if (lowerName.includes(term)) return 'female';
|
|
}
|
|
|
|
// Common male/female voice names
|
|
if (/(david|james|john|paul|mark|thomas|daniel|jack|william|george|michael|robert|peter|brian|richard|steve|bruce)/i.test(lowerName)) {
|
|
return 'male';
|
|
}
|
|
|
|
if (/(mary|sarah|emma|susan|julia|karen|lisa|anna|laura|amy|elizabeth|jennifer|maria|emily|jessica|alice|victoria)/i.test(lowerName)) {
|
|
return 'female';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* 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));
|
|
}
|
|
}
|
|
}
|