Source: TextToSpeechPlayer.js

/**
 * @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 }