/**
* @typedef {Object} TextToSpeechOptions
* @property {string} [language='it-IT'] - Language code for speech synthesis (e.g., 'en-US', 'it-IT')
* @property {number} [rate=1.0] - Speech rate (0.1 to 10)
* @property {number} [volume=1.0] - Speech volume (0 to 1)
* @property {boolean} [cleanText=true] - Whether to remove HTML tags and format text
* @property {number} [voiceSelected=-1] - Index of preferred voice (-1 for auto-selection)
*/
/**
*
* TextToSpeechPlayer provides text-to-speech functionality with extensive control options.
* Handles voice selection, speech synthesis, text cleaning, and playback control.
*
* Features:
* - Multiple language support
* - Automatic voice selection
* - Text cleaning and formatting
* - Playback controls (pause, resume, stop)
* - Volume control with mute option
* - Offline capability detection
* - Chrome speech bug workarounds
* - Page visibility handling
*
* Browser Compatibility:
* - Uses Web Speech API
* - Implements Chrome-specific fixes
* - Handles browser tab switching
* - Manages page unload events
*
*
* Implementation Details
*
* Chrome Bug Workarounds:
* - Implements periodic pause/resume to prevent Chrome from stopping
* - Uses timeout to prevent indefinite speech
* - Handles voice loading race conditions
*
* State Management:
* ```javascript
* {
* isSpeaking: boolean, // Current speech state
* isPaused: boolean, // Pause state
* voice: SpeechSynthesisVoice, // Selected voice
* isOfflineCapable: boolean, // Offline support
* volume: number, // Current volume
* previousVolume: number // Pre-mute volume
* }
* ```
*
* Event Handling:
* - beforeunload: Stops speech on page close
* - visibilitychange: Handles tab switching
* - voiceschanged: Manages voice loading
* - utterance events: Tracks speech progress
*/
class TextToSpeechPlayer {
/**
* Creates a new TextToSpeechPlayer instance
* @param {TextToSpeechOptions} [options] - Configuration options
*
* @example
* ```javascript
* const tts = new TextToSpeechPlayer({
* language: 'en-US',
* rate: 1.2,
* volume: 0.8,
* cleanText: true
* });
* ```
*/
constructor(options) {
// Default configuration
this.config = {
language: 'it-IT',
rate: 1.0,
volume: 1.0,
cleanText: true,
voiceSelected: -1
};
// Apply user options
if (options) {
Object.assign(this.config, options);
}
// State properties
this.voice = null;
this.isSpeaking = false;
this.currentUtterance = null;
this.isOfflineCapable = false;
this.resumeTimer = null;
this.timeoutTimer = null;
this.isPaused = false;
this.previousVolume = this.config.volume;
this.intentionalStop = false;
this._resolveCurrentSpeech = null;
// Check if Speech Synthesis API is supported
if (!window.speechSynthesis) {
console.error("Speech Synthesis API is not supported in this browser");
}
}
/**
* Initializes the player by loading voices and checking capabilities
* @returns {Promise<void>}
* @throws {Error} If voice loading fails or no suitable voices found
*
* Initialization steps:
* 1. Loads available voices
* 2. Selects appropriate voice
* 3. Checks offline capability
* 4. Sets up page listeners
*/
async initialize() {
try {
// Ensure Speech Synthesis API is available
if (!window.speechSynthesis) {
throw new Error("Speech Synthesis API is not supported in this browser");
}
// Pre-warm the speech synthesis engine with a silent utterance
// This solves the "first click does nothing" issue in some browsers
await this.warmUpSpeechSynthesis();
await this.loadVoice();
this.checkOfflineCapability();
this.setupPageListeners();
console.log("TextToSpeechPlayer initialized successfully");
console.log(`Offline capable: ${this.isOfflineCapable}`);
return true;
} catch (error) {
console.error("Failed to initialize TextToSpeechPlayer:", error);
throw error;
}
}
/**
* Warms up the speech synthesis engine with a silent utterance.
* This helps with the first-time initialization in some browsers.
* @private
*/
async warmUpSpeechSynthesis() {
return new Promise((resolve) => {
try {
// Create a silent utterance (space character with zero volume)
const emptyUtterance = new SpeechSynthesisUtterance(" ");
emptyUtterance.volume = 0;
// Ensure it completes quickly
emptyUtterance.rate = 2;
emptyUtterance.onend = () => {
resolve();
};
emptyUtterance.onerror = () => {
// Even if there's an error, we should continue
resolve();
};
// Set a timeout in case the event doesn't fire
setTimeout(resolve, 500);
// Speak the empty utterance
window.speechSynthesis.speak(emptyUtterance);
} catch (e) {
console.warn("Failed to warm up speech synthesis", e);
resolve();
}
});
}
/**
* Sets up event listeners for page visibility changes and unload events.
* @private
*/
setupPageListeners() {
// For page close/refresh
window.addEventListener('beforeunload', () => {
this.stopSpeaking();
});
// For page visibility change (e.g., switching tabs)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stopSpeaking();
}
});
}
/**
* Activates the TextToSpeechPlayer.
*/
activate() {
this.isSpeaking = true;
}
/**
* Loads and selects appropriate voice for synthesis.
*
* @returns {Promise<SpeechSynthesisVoice>}
* @throws {Error} If no suitable voice is found
* @private
*/
async loadVoice() {
console.log(`Loading voice for language: ${this.config.language}`);
return new Promise((resolve, reject) => {
const synth = window.speechSynthesis;
// Function to set voice based on available voices
const setVoice = () => {
let voices = synth.getVoices();
if (voices.length === 0) {
console.warn("No voices available for this browser");
reject(new Error("No voices available for this browser"));
return;
}
// Select voice based on index if provided
if (this.config.voiceSelected >= 0 && this.config.voiceSelected < voices.length) {
this.voice = voices[this.config.voiceSelected];
} else {
// Otherwise select by language
const firstTwo = this.config.language.substring(0, 2);
this.voice = voices.find(v => v.lang.startsWith(firstTwo));
}
if (this.voice) {
console.log(`Voice loaded: ${this.voice.name}`);
resolve(this.voice);
} else {
console.warn(`No suitable voice found for language: ${this.config.language}`);
reject(new Error(`No voice available for ${this.config.language}`));
}
};
// Try to set voice immediately if already available
if (synth.getVoices().length > 0) {
setVoice();
} else {
// Otherwise wait for voices to load
synth.onvoiceschanged = () => {
setVoice();
synth.onvoiceschanged = null;
};
// Set a timeout in case onvoiceschanged doesn't fire
setTimeout(() => {
if (!this.voice) {
console.warn("Timeout while waiting for voices to load");
setVoice();
}
}, 1000);
}
});
}
/**
* Checks if the selected voice is capable of offline speech synthesis.
*
* @private
*/
checkOfflineCapability() {
if (this.voice) {
// A voice is offline capable if localService is true (not false as in original code)
this.isOfflineCapable = this.voice.localService;
} else {
this.isOfflineCapable = false;
}
}
/**
* Cleans text by removing HTML tags and formatting.
*
* Cleaning steps:
* 1. Removes 'omissis' class content
* 2. Converts <br> to spaces
* 3. Strips HTML tags
* 4. Removes escape characters
* 5. Trims whitespace
*
* @param {string} text - Text to clean
* @returns {string} Cleaned text
* @private
*/
cleanTextForSpeech(text) {
if (!text) return "";
// Remove content of any HTML tag with class "omissis" (with or without escaped quotes)
let cleanedText = text.replace(/<[^>]+class=(\"omissis\"|"omissis")[^>]*>[\s\S]*?<\/[^>]+>/g, "");
// Substitute <br> tag with whitespace " "
cleanedText = cleanedText.replace(/<br\s*\/?>/gi, " ");
// Remove HTML tags
cleanedText = cleanedText.replace(/<\/?[^>]+(>|$)/g, "");
// Remove escape characters like \n, \t, etc.
cleanedText = cleanedText.replace(/\\[nrt]/g, " ");
// Trim leading and trailing whitespace
return cleanedText.trim();
}
/**
* Speaks the provided text
* @param {string} text - Text to be spoken
* @returns {Promise<void>}
* @throws {Error} If speech synthesis fails or times out
*
* Processing steps:
* 1. Cancels any ongoing speech
* 2. Cleans input text if enabled
* 3. Creates utterance with current settings
* 4. Handles speech synthesis
* 5. Manages timeouts and Chrome workarounds
*
* @example
* ```javascript
* await tts.speakText("Hello, world!");
* ```
*/
async speakText(text) {
// First stop any current speech
this.stopSpeaking();
if (!text) {
console.warn("No text provided to speak");
return;
}
if (!this.voice) {
console.error("Voice not loaded. Please initialize TextToSpeechPlayer first.");
return;
}
if (!this.isOfflineCapable && !navigator.onLine) {
console.error("No internet connection and offline speech is not available.");
return;
}
// Set speaking state
this.isSpeaking = true;
this.isPaused = false;
// Process text if needed
let cleanedText = text;
if (this.config.cleanText) {
cleanedText = this.cleanTextForSpeech(text);
}
if (!cleanedText) {
console.warn("Text is empty after cleaning");
this.isSpeaking = false;
return;
}
console.log("Attempting to speak:", cleanedText);
const synth = window.speechSynthesis;
// Ensure the synthesis system is active (fixes Chrome/Firefox first-time issues)
synth.cancel();
// Store whether we intentionally cancelled speech
this.intentionalStop = false;
try {
// Create new utterance
this.currentUtterance = new SpeechSynthesisUtterance(cleanedText);
this.currentUtterance.lang = this.config.language;
this.currentUtterance.voice = this.voice;
this.currentUtterance.rate = this.config.rate;
this.currentUtterance.volume = this.config.volume;
// Handle speaking process
await new Promise((resolve, reject) => {
// Store the resolve function so we can call it from stopSpeaking
this._resolveCurrentSpeech = resolve;
this.currentUtterance.onend = () => {
resolve('completed');
};
this.currentUtterance.onerror = (event) => {
// Don't treat intentional stops as errors
if (this.intentionalStop && event.error === 'interrupted') {
console.log("Speech intentionally interrupted");
resolve('interrupted');
} else {
console.error("Speech error:", event);
reject(event);
}
};
// Start speaking
synth.speak(this.currentUtterance);
// Force Chrome to start speaking immediately (fixes first-play issues)
if (!this.isPaused && synth.speaking) {
synth.pause();
synth.resume();
}
// Timeout to prevent speech from running indefinitely
const maxSpeechTime = Math.max(5000, cleanedText.length * 100); // At least 5 seconds
this.timeoutTimer = setTimeout(() => {
if (synth.speaking && this.isSpeaking) {
console.warn("Speech synthesis taking too long. Resetting...");
this.intentionalStop = true;
this.stopSpeaking();
resolve('timeout');
}
}, maxSpeechTime);
// Workaround for Chrome bug - resume speech every 10 seconds
this.resumeTimer = setInterval(() => {
if (!synth.speaking) {
clearInterval(this.resumeTimer);
this.resumeTimer = null;
} else if (!this.isPaused) {
// Only pause and resume if not manually paused
synth.pause();
synth.resume();
}
}, 10000);
});
} catch (error) {
// Only log errors that aren't related to intentional stopping
if (!(this.intentionalStop && error.error === 'interrupted')) {
console.error("Error during speech:", error);
}
} finally {
// Clean up regardless of outcome
if (this.isSpeaking) {
this.stopSpeaking();
}
}
}
/**
* Pauses or resumes speech synthesis
* @param {boolean} enable - True to pause, false to resume
*
* @example
* ```javascript
* // Pause speech
* tts.pauseSpeaking(true);
*
* // Resume speech
* tts.pauseSpeaking(false);
* ```
*/
pauseSpeaking(enable) {
if (!window.speechSynthesis || !this.isSpeaking) {
console.log("No speech in progress to pause/resume");
return;
}
const synth = window.speechSynthesis;
if (enable && !this.isPaused) {
// Pause speech
synth.pause();
this.isPaused = true;
// Clear the resume timer when pausing
if (this.resumeTimer) {
clearInterval(this.resumeTimer);
this.resumeTimer = null;
}
} else if (!enable && this.isPaused) {
// Resume speech
synth.resume();
this.isPaused = false;
// Restart the resume timer for Chrome bug workaround
this.resumeTimer = setInterval(() => {
if (!synth.speaking) {
clearInterval(this.resumeTimer);
this.resumeTimer = null;
} else {
synth.pause();
synth.resume();
}
}, 10000);
}
}
/**
* Mutes or unmutes audio output
* @param {boolean} enable - True to mute, false to unmute
*
* @example
* ```javascript
* // Mute audio
* tts.mute(true);
*
* // Restore previous volume
* tts.mute(false);
* ```
*/
mute(enable) {
if (enable) {
this.previousVolume = this.config.volume;
this.config.volume = 0;
} else {
this.config.volume = this.previousVolume;
}
// Update current utterance if speaking
if (this.currentUtterance) {
this.currentUtterance.volume = this.config.volume;
}
}
/**
* Stops current speech synthesis
* Cleans up resources and resets state
*/
stopSpeaking() {
const synth = window.speechSynthesis;
// Mark that we're intentionally stopping speech to handle the error properly
this.intentionalStop = true;
// Cancel speech if speaking
if (synth && synth.speaking) {
try {
synth.cancel();
} catch (e) {
console.error("Error cancelling speech:", e);
}
}
// Clear timers
if (this.resumeTimer) {
clearInterval(this.resumeTimer);
this.resumeTimer = null;
}
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
// Resolve any pending promise to prevent unhandled rejections
if (this._resolveCurrentSpeech) {
this._resolveCurrentSpeech('stopped');
this._resolveCurrentSpeech = null;
}
// Reset state
this.currentUtterance = null;
this.isSpeaking = false;
this.isPaused = false;
}
}
export { TextToSpeechPlayer }