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) {
    Object.assign(this, {
      language: 'it-IT',
      rate: 1.0,
      volume: 1.0,
      cleanText: true,
      voiceSelected: -1
    });
    Object.assign(this, options);

    this.voice = null;
    this.isSpeaking = false;
    this.currentUtterance = null;
    this.isOfflineCapable = false;
    this.resumeTimer = null;
    this.timeoutTimer = null;
    this.isPaused = false;
    this.previousVolume = this.volume;
  }

  /**
   * 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 {
      await this.loadVoice();
      this.checkOfflineCapability();
      console.log("TextToSpeechPlayer initialized successfully");
      console.log(`Offline capable: ${this.isOfflineCapable}`);
    } catch (error) {
      console.error("Failed to initialize TextToSpeechPlayer:", error);
      throw error;
    }
    this.setupPageListeners();
  }

  /**
   * 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.language}`);
    return new Promise((resolve, reject) => {
      let synth = window.speechSynthesis;

      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`));
        }
        if (this.voiceSelected >= 0) {
          this.voice = voices[this.voiceSelected];
        } else {
          const firstTwo = this.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.language}`);
          reject(new Error(`No voice available for ${this.language}`));
        }
      };

      if (synth.getVoices().length > 0) {
        setVoice();
      } else {
        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) {
      // If a voice is loaded and it's not marked as a network voice, assume it's offline capable
      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) {
    // 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) {
    if (window.speechSynthesis.speaking) {
      window.speechSynthesis.cancel();
    }
    this.currentUtterance = null;

    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;
    }

    this.isSpeaking = true;
    let cleanedText = text;
    if (this.cleanText)
      cleanedText = this.cleanTextForSpeech(text);
    console.log("Attempting to speak:", cleanedText);

    if (window.speechSynthesis.speaking) {
      window.speechSynthesis.cancel();
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    let synth = window.speechSynthesis;
    this.currentUtterance = new SpeechSynthesisUtterance(cleanedText);
    this.currentUtterance.lang = this.language;
    this.currentUtterance.voice = this.voice;
    this.currentUtterance.rate = this.rate;
    this.currentUtterance.volume = this.volume;

    try {
      await new Promise((resolve, reject) => {
        this.currentUtterance.onend = () => {
          resolve();
        };
        this.currentUtterance.onerror = (event) => {
          console.error("Speech error:", event);
          reject(event);
        };

        synth.speak(this.currentUtterance);
        // Timeout to prevent speech from running indefinitely if it takes too long
        let maxSpeechTime = cleanedText.length * 800;
        this.timeoutTimer = setTimeout(() => {
          if (synth.speaking && this.isSpeaking) {
            console.warn("Speech synthesis taking too long. Resetting...");
            synth.cancel();
            clearInterval(this.resumeTimer);
            reject(new Error("Speech synthesis timeout"));
          }
        }, maxSpeechTime);
        // Workaround to resume speech every 10 seconds
        this.resumeTimer = setInterval(() => {
          console.log(speechSynthesis.speaking);
          if (!speechSynthesis.speaking) {
            clearInterval(this.resumeTimer);
          } else if (!this.isPaused) { // Only pause and resume if not manually paused
            speechSynthesis.pause();
            speechSynthesis.resume();
          }
        }, 10000);
      });
    } catch (error) {
      console.error("Error during speech:", error);
    }
    clearTimeout(this.timeoutTimer);
    this.timeoutTimer = null;
    clearInterval(this.resumeTimer);
    this.resumeTimer = null;
    this.currentUtterance = null;
  }

  /**
   * 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 (!this.currentUtterance) {
      console.log("No speech in progress to pause/resume");
      return;
    }
    if (enable && !this.isPaused) {
      window.speechSynthesis.pause();
      this.isPaused = true;
      clearInterval(this.resumeTimer); // Clear the resume timer when pausing
    } else if (!enable && this.isPaused) {
      window.speechSynthesis.resume();
      this.isPaused = false;
      // Restart the resume timer
      this.resumeTimer = setInterval(() => {
        if (!speechSynthesis.speaking) {
          clearInterval(this.resumeTimer);
        } else {
          speechSynthesis.pause();
          speechSynthesis.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.volume;
      this.volume = 0;
    } else {
      this.volume = this.previousVolume;
    }
  }

  /**
   * Stops current speech synthesis
   * Cleans up resources and resets state
   */
  stopSpeaking() {
    if (this.isSpeaking) {
      if (window.speechSynthesis.speaking) {
        window.speechSynthesis.cancel();
      }
      this.currentUtterance = null;
      clearInterval(this.resumeTimer);
      this.resumeTimer = null;
    }
    this.isSpeaking = false;
  }
}

export { TextToSpeechPlayer }