class TWXVoiceAgent {

    /*
    Class for managing voice recognition (STT) and output (TTS). 
    Allows for continuous user listening and interruption of agent speech.
    NOTE: If multiple browser tabs have microphones listening, we can get recognition.onstart failures. These are detected in this class (see console.log)
    TODO: inner class VAD for advanced Voice Activity Detection (voice fingerprinting / noise detection)

    = 3.1.0 = 
    * Use Flag instead of stop/start recognition.
    * Better browser voice selection, neural etc. Voice methods now available and constructor param to select default voice by name
    * Setence chunking (might have been in prev version)

    = 3.2.2 = 
    * New method testAgentSpeechEvents() to return if Utterance Events not firing. Client can then elect to downgrade Voice to local service which usually allow events.
    * Implemeneted Watchdog for when Utterance events not firing, will simulate Utterance onend event - timing still not the best

    TODO
    * Bug: Still collects utterances in event even when 'Ignore User' selected from Test Harness. Need a way to flush or further ignore in rec.onend, or flush on when Ignore User set FALSE
    * Enh: Allow client to select wether recognition is ON or OFF during Agent Speech (in Continuous Listening mode). Android clients may want to turn off to avoid the hardware tones.
    * Enh: Android continuous not that great, try combining with VAD (fingerprint first users speech and compare in later listens). 
            Also check out https://github.com/alphacep/vosk-api
            Also check out sendSpeechToOpenAI() method in twx-chat-stt.js
    * Enh: With Agent Speech events, pass back current sentence number of total being spoken, allow client to have progress/montior etc

    Agent Speech volume note:
    ------------------------
    According to CHATGPT if the mic is on, Safari puts audio session in 'high priority' mode (maybe for Siri etc) and boosts volume, but when off it is lower.
    Can test this in Test Harness by clicking 'Go!' button and then click 'Test Spk'. Then 'Stop Listen' then 'Test Spk'.

    */

    static getVer() { 
        return '3.2.3';
    }

    constructor(
        {
            continuousListening = true, 
            allowAgentInterrupt = true, 
            voiceName = null,
            voiceVolume = 1.0
        }){

        this.useSpeechGapDetection = TWXVoiceAgent.isSafari();

        this.eventTarget = new EventTarget();

        // STT
        this.isListeningForSpeech = false;
        this.recognition = null;
        this.userSpeechGapTimer = null;
        this.finalSpeechTranscript = '';
        this.continuousListening = continuousListening; // wont stop recognition after user speech detected, ie: keeps listening until user force stop
        this.allowAgentInterrupt = allowAgentInterrupt; // if false, will disable listening during agent speech
        this.stopRecognitionDuringAgentSpeech = TWXVoiceAgent.isAndroid() && this.continuousListening;
        this.clientStoppedListening = true; // Allow client to permanently disable Listening until switch back ON, ie: no restarts. Initlialize as true until client listens
        this.isRestartingListener = false; // used to prevent emit events to client on no-speech timeouts
        this.lastListenerRestart = null; // sometimes we get into a listener restart -> onerror -> onend -> restart loop. This helps to prevent this
        this.ignoreInterimRecognitionResults = false; // used during final speech gap timer to ignore subsequent interim results
        this.clientWantsToIgnoreUserUtterances = false; // flag set by client to effectively mute the microphone and stop agent from emitting recognition events (without physically stopping recognition)
        this.lastRecognitionError = '';
        //this.clientJustSetUserUtteranceHear = false;

        // NOTE: we need a way to stop Android from sounding recognition off and on so much during continuous listening. Maybe when Agent is speaking we can turn off 
        // recognition and restart it afterwards. For other platforms we can keep the recognition on and use flag as we do now.

        console.log(`[va] continuousListening: ${this.continuousListening ? 'true' : 'false'}, allowAgentInterrupt: ${this.allowAgentInterrupt ? 'true' : 'false'}`);
        console.log(`[va] isSafari: ${TWXVoiceAgent.isSafari()}, isAndroid: ${TWXVoiceAgent.isAndroid()}`);

        // VAD (Voice Activity Detector - can assist with voice fingerprinting and possibly prevent Agent interrupting itself)
        this.vad = null;
        //this.lastVADVoiceActivity = null;
        //TWXVoiceChat.isAndroid() && this.#setupVAD();

        //TTS
        this.systemVoice = null;
        this.voiceVolume = voiceVolume;
        this.availableVoices = [];
        this.hasSpokenYet = false;
        this.isAgentSpeaking = false;
        this.agentSpeechTimeout = null;
        this.agentJustFinishedSpeaking = false;
        this.utter = null;
        this.microphoneAvailable = false;
        this.agentSpeechQueue = [];
        this.isProcessingAgentSpeechQueue = false;
        this.isAgentSpeechEventsWorkingProp = false;

         // set available voices and system voice
        this.#setVoices(voiceName);

        this.#isMicrophoneAvailableInternal().then((available) => {
            this.microphoneAvailable = available;
            console.log(`[va] Microphone ${available ? 'available' : 'unavailable'}`);
        });        

        if(this.allowAgentInterrupt && !TWXVoiceAgent.isSafari()){
            console.log(`[va] WARNING! Agent Interruption is enabled, however Non-Safari browsers will tend to interrupt self when Agent is speaking. Working on it!`);
        }

    }

    // Public Methods

    static isSafari() {
        return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
    }

    static isAndroid(){
        return /Android/i.test(navigator.userAgent);
    }

    static splitTextIntoSentences(text) {
        const parsed = text.match(/[^.!?]+[.!?]+(\s|$)/g) || [];
        const sentences = parsed
            .map(sentence => sentence.trim())
            .filter(sentence =>
                sentence.length > 1 &&           // More than one character
                /[a-zA-Z0-9]/.test(sentence)     // Contains at least one alphanumeric character
            );
        return sentences;
    }

    isMicrophoneAvailable(){
        return this.microphoneAvailable;
    }

    isListening(){
        return this.isListeningForSpeech;
    }

    isContinuousListening(){
        return this.continuousListening;
    }

    isAllowAgentInterrupt(){
        return this.allowAgentInterrupt;
    }

    isSpeaking(){
        return this.isAgentSpeaking;
    }

    hasSpokenYet(){
        return this.hasSpokenYet;
    }

    isPaused(){
        if(!this.utter) return false;
        if(window.speechSynthesis.paused) return true;
        return false;
    }

    isAgentSpeechEventsWorking(){
        return this.isAgentSpeechEventsWorkingProp;
    }

    setIgnoreUserSpeech(ignore){
        // Allows client to detemine when to ignore user speech, eg: before Polly speech and then unset afterwards
        console.log(`[va] setIgnoreUserSpeech: ${ignore ? 'true' : 'false'}`);
        /*
        if(this.clientWantsToIgnoreUserUtterances && !ignore) {
            this.clientJustSetUserUtteranceHear = true;
        }else{
            this.clientJustSetUserUtteranceHear = false;
        }
        */    
        this.clientWantsToIgnoreUserUtterances = ignore;
    }

    setVoice(voiceName){
        // can be null which will set back to default
        this.#setVoices(voiceName); // like constructor does
    }

    getVoice(){
        return this.systemVoice;
    }

    // startListenForSpeech
    listen(isClient = true) {

        // For Safari, if this is the first listen event, we need to test speak first before listen otherwise can't speak at all later FSR
        if(!this.hasSpokenYet && TWXVoiceAgent.isSafari()){
            //console.log('[va] test blank speech to get audioContext');
            this.hasSpokenYet = true;
            const synth = window.speechSynthesis;
            if(synth){
                const utter = new window.SpeechSynthesisUtterance(' ');
                synth.speak(utter);
            }
        }

        if(isClient) this.clientStoppedListening = false;
        this.isAgentSpeaking && this.stopSpeaking(false);
        
        // initialize recognition object
        this.#initSpeechRecognition(); // should stop any current listening
        if(!this.recognition){
            console.log('[va] cannot initialise recognition. abort');
            return;
        }
        console.log('[va] Starting recognition...');

        try {
            this.recognition.start();
        } catch (err) {
            console.warn('[va] Failed to start recognition:', err);
        }

    }

    stopListening(isClient = true){

        // This function will completely stop the listener/recognizer (ie: microphpone in browser tab will dissapear)
        // Is is called by the client, we want to remember that to prevent the listener from restarting
        console.log('[va] force stop listening');
        const emit = (this.recognition != null) || this.isListeningForSpeech;
        if(isClient) this.clientStoppedListening = true;
        this.useSpeechGapDetection && this.#clearUserSpeechGapTimer();
        this.#destroySpeechRecognition();
        this.isListeningForSpeech = false;
        emit && this.#emitEvent('onListenEnd',{});
    }

    startVAD(){
        this.#setupVAD();
    }

    stopVAD(){
        this.vad && this.vad.disconnect();
    }

    getAvailableVoices(){
        return this.availableVoices;
    }

    speak(text) {
        //console.log('[va] speak():', text);

        text = text.trim();
        if(!text) {
            console.log('[va] missing/empty text. Agent speech aborted');
            return;
        }

        const synth = window.speechSynthesis;
        if(!synth){
            console.log('[va] speak(). speech synthesis not active. abort');
            return;
        }
        synth.cancel(); // Cancel any previous utterances TODO maybe peform force stop (stopSpeaking)??

        this.#startUtter(text);

        /* June-2025: We used to have to split large text into sentences, but lately Chrome is allowing longer speech
        // Split text into sentences
        const sentences = TWXVoiceAgent.splitTextIntoSentences(text);
        if (sentences.length === 1) {
            this.#startUtter(text);
            return;
        }
        
        console.log(`[va] Adding ${sentences.length} sentences to the queue:`,sentences);
        this.agentSpeechQueue = sentences;
        
        // Start processing the queue
        this.isProcessingAgentSpeechQueue = true;
        this.#speakNextInAgentSpeechQueue(); // utterance events will continue to call this until empty
        */
    }

    testAgentSpeechEvents() {

        const timeoutMs = 1000;
        this.isAgentSpeechEventsWorkingProp = false;

        return new Promise((resolve) => {
            if (!('speechSynthesis' in window)) {
                console.warn('[va] SpeechSynthesis is not supported in this browser.');
                return resolve(false);
            }

            let ended = false;
            const utterance = new SpeechSynthesisUtterance(" ");
            utterance.volume = 0;
            if(this.systemVoice) utterance.voice = this.systemVoice;

            utterance.onend = () => {
                ended = true;
                this.isAgentSpeechEventsWorkingProp = true;
                resolve(true); // Event fired, success
            };

            utterance.onerror = (e) => {
                //console.error("[va] SpeechSynthesis error:", e.error);
                resolve(false); // Error occurred, treat as failure
            };

            // Safety timeout in case onend doesn't fire
            const timeout = setTimeout(() => {
                if (!ended) {
                    //console.warn("[va] onend event did not fire within timeout.");
                    window.speechSynthesis.cancel(); // otherwise .speaking is true forever. triggers onerror
                    resolve(false);
                }
            }, timeoutMs);

            // Cleanup after either resolve path
            const finish = (result) => {
                clearTimeout(timeout);
                resolve(result);
            };

            try {
                window.speechSynthesis.cancel(); // Clear anything queued
                window.speechSynthesis.speak(utterance);
            } catch (e) {
                console.error("[va] Error starting speech synthesis:", e);
                finish(false);
            }
        });
    }

    stopSpeaking(isClient = true){
        console.log(`[va] ${isClient ? 'client':'internal'} force stop speak`);
        const emit = (this.utter !== null) || this.isAgentSpeaking;
        this.agentSpeechQueue = [];
        this.isProcessingAgentSpeechQueue = false;
        this.#destroyUtter();
        this.isAgentSpeaking = false;
        emit && this.#emitEvent('onAgentSpeechEnd',{});
        if(this.continuousListening && !this.isListeningForSpeech && !this.clientStoppedListening) this.listen(false);
    }

    resumeSpeaking(){
        console.log('[va] resume speaking');

        /* this was causing issues with play/pause function in client
        if (window.speechSynthesis.paused &&
            (window.speechSynthesis.speaking || window.speechSynthesis.pending)) {
            window.speechSynthesis.resume();
        }else{
            // nothing to resume, emit event
            this.stopSpeaking(false);
        }
        */

        //this.utter && window.speechSynthesis.resume();
        window.speechSynthesis.resume();
    }

    pauseSpeaking() {
        console.log('[va] pause speaking');
        window.speechSynthesis.pause();
    }

    dispose(){
        // call this if we are destroying the instance for any reason

        // TTS
        this.agentSpeechQueue = [];
        this.isProcessingAgentSpeechQueue = false;
        this.#destroyUtter();
        this.isAgentSpeaking = false;

        // STT
        this.clientStoppedListening = true;
        this.useSpeechGapDetection && this.#clearUserSpeechGapTimer();
        this.#destroySpeechRecognition();
        this.isListeningForSpeech = false;

        // VAD
        this.stopVAD();
    }

    // Events
    // Agent speech end event
    onAgentVoiceNotFound(callback) { 
        this.eventTarget.addEventListener('onAgentVoiceNotFound', callback);
    }   

    // On Listening Start event (not restart)
    onListenStart(callback) { 
        this.eventTarget.addEventListener('onListenStart', callback);
    }    

    // On Listening End event
    onListenEnd(callback) { 
        this.eventTarget.addEventListener('onListenEnd', callback);
    }    

    // On User Speech received event
    onInterimUserSpeechTranscribed(callback) { 
        this.eventTarget.addEventListener('interimUserSpeechTranscribed', callback);
    }    

    // On User Speech ended event
    onFinalUserSpeechTranscribed(callback) { 
        this.eventTarget.addEventListener('finalUserSpeechTranscribed', callback);
    }    

    // Agent speech started event
    onAgentSpeechStart(callback) { 
        this.eventTarget.addEventListener('onAgentSpeechStart', callback);
    }  
    
    // Agent speech interim word(s) spoken event
    onAgentSpeechInterim(callback){
        this.eventTarget.addEventListener('onAgentSpeechInterim', callback);
    }

    // Returns sentence about to be spoken by agent and number of sentences left
    onAgentSpeechSentence(callback){
        this.eventTarget.addEventListener('onAgentSpeechSentence', callback);
    }

    // Agent speech end event
    onAgentSpeechEnd(callback) { 
        this.eventTarget.addEventListener('onAgentSpeechEnd', callback);
    }   

    downgradeVoiceToLocal(){
        const voices = this.availableVoices;
        if(!voices) return;
        for(let i=0;i<voices.length;i++){
            const voice = voices[i];
            if(voice.localService){
                this.systemVoice = voice;
                console.log(`[va] Agent Speech voice downgraded to: ${voice.name}`);
                return true;
            }
        }
        return false;
    }

    //TODO
    onVoiceActivityDetected(callback) { 
        this.eventTarget.addEventListener('voiceActivityDetected', callback);
    }   

    // Private Methods

    #setVoices(voiceName) {

        // load all available voices and if voiceName requested, set that too
        this.availableVoices = [];
        this.systemVoice = null;

        //console.log(`requested voice: "${voiceName}"`);

        const enumerateVoices = () => {
          const voices = window.speechSynthesis.getVoices();
          voices.forEach((v, i) => {
            //console.log(`${i + 1}. ${v.name} | lang: ${v.lang} | localService: ${v.localService}`);
            this.availableVoices.push(v);
            if(voiceName && v.name === voiceName){
                console.log(`[va] the requested voice '${voiceName}' is selected`);
                this.systemVoice = v;
            }
          });
          //console.log(`[va] ${this.availableVoices.length} browser voices available`);
          if(voiceName && !this.systemVoice) {
            console.log(`[va] the requested voice '${voiceName}' is not found. using default browser voice`);
            this.#emitEvent('onAgentVoiceNotFound',{});
          }

        };
      
        // Voices might not be ready immediately
        if (window.speechSynthesis.getVoices().length === 0) {
          window.speechSynthesis.onvoiceschanged = enumerateVoices;
        } else {
            enumerateVoices();
        }
    }
      
    #initSpeechRecognition(){
        this.#destroySpeechRecognition();
        const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
        if(!SpeechRecognition) {
            this.recognition = null;
            console.log('[va] initSpeechRecognition(). Speech Recognition not supported by this browser');
            return;
        }
        this.recognition = new SpeechRecognition();
        this.recognition.lang = 'en-US';
        this.recognition.interimResults = true; // Get interim results for live speech detection
        this.recognition.maxAlternatives = 1;
        this.recognition.continuous = false;

        this.recognition.onstart = () => {
            this.finalSpeechTranscript = '';
            this.isListeningForSpeech = true;
            this.ignoreInterimRecognitionResults = false;
            this.lastRecognitionError = '';

            if(this.isRestartingListener){
                //console.log('[va] recognition.onstart restart');
                this.isRestartingListener = false;
            }else{
                //console.log('[va] recognition.onstart');
                this.#emitEvent('onListenStart',{});
            }
        };
        this.recognition.onaudiostart = () => {
            //console.log('[va] recognition.onaudiostart');
        };
        this.recognition.onsoundstart = () => {
            //console.log('[va] recognition.onsoundstart');
        };
        this.recognition.onspeechstart = () => {
            //console.log('[va] recognition.onspeechstart');
        };
        this.recognition.onspeechend = () => {
            //console.log('[va] recognition.onspeechend');
        };
        this.recognition.onsoundend = () => {
            //console.log('[va] recognition.onsoundend');
        };
        this.recognition.onaudioend = () => {
            //console.log('[va] recognition.onaudioend');
        };

        this.recognition.onresult = (event) => {
            //console.log('[va] recognition.onresult() clientWantsToIgnoreUserUtterances: ',this.clientWantsToIgnoreUserUtterances);

            const shouldIgnore = ((!this.allowAgentInterrupt && (this.isAgentSpeaking || this.agentJustFinishedSpeaking)) || this.clientWantsToIgnoreUserUtterances || this.ignoreInterimRecognitionResults);
            if(shouldIgnore) return;
            
            let interim = '';
            for (let i = event.resultIndex; i < event.results.length; ++i) {
                const transcript = event.results[i][0].transcript;
                if (event.results[i].isFinal) {
                    this.finalSpeechTranscript = transcript;
                    //console.log(`[va] recognition.onresult() final: "${transcript}"`);
                } else {
                    interim += transcript;
                    if(this.continuousListening) this.finalSpeechTranscript = interim;
                }
            }

            if(interim){
                //console.log(`[va] recognition.onresult() interim: "${interim}"`);
                this.#emitEvent('interimUserSpeechTranscribed',{transcript: interim.trim()});
                this.allowAgentInterrupt && this.isAgentSpeaking && this.stopSpeaking(false);
                this.useSpeechGapDetection && this.#resetUserSpeechGapTimer();
            }
            // Don't emit final event and stop yet, wait for user speech gap timeout or recognition.onend
        };

        this.recognition.onerror = (event) => {
            if(event.error !== 'no-speech') console.log('[va] recognition.onerror', event.error);
            this.useSpeechGapDetection && this.#clearUserSpeechGapTimer(); // may not need if onend always invoked after
            this.lastRecognitionError = event.error;
            this.isListeningForSpeech = false;
            // will now invoke onend event
        };

        this.recognition.onend = () => {
            // occurs on final user utterance or force recogniton.stop or after recogntion error
            //console.log(`[va] recognition.onend`);
            //console.log('[va] recognition.onend() clientWantsToIgnoreUserUtterances: ',this.clientWantsToIgnoreUserUtterances);
            //console.log('[va] allowAgentInterrupt:',this.allowAgentInterrupt,'. isAgentSpeaking:',this.isAgentSpeaking,'. agentJustFinishedSpeaking:',this.agentJustFinishedSpeaking);
            this.useSpeechGapDetection && this.#clearUserSpeechGapTimer();

            // emit final transcribe event if not using manual Speech Gap detection. If using manual Speech Gap detection, that timer will handle emit.
            if(!this.useSpeechGapDetection && this.finalSpeechTranscript){
                const shouldIgnore = (
                    (!this.allowAgentInterrupt && (this.isAgentSpeaking || this.agentJustFinishedSpeaking)) 
                    || this.clientWantsToIgnoreUserUtterances 
                    || this.ignoreInterimRecognitionResults
                );
                //  removed from above:  || this.clientJustSetUserUtteranceHear 

                if(shouldIgnore){
                    if(this.clientWantsToIgnoreUserUtterances){  // || this.clientJustSetUserUtteranceHear){
                        console.log('[va] heard final user utterance but ignoring as instructed');
                    }else{
                        console.log('[va] not allowed to interrupt agent speaking');
                    }
                }else{
                    console.log('[va] emit finalUserSpeechTranscribed:',this.finalSpeechTranscript);
                    this.#emitEvent('finalUserSpeechTranscribed',{transcript: this.finalSpeechTranscript.trim()});
                }
            }
            // this needs to be here to allow user speech again if previously ignoring
            if(this.agentJustFinishedSpeaking) this.agentJustFinishedSpeaking = false;
            //if(this.clientJustSetUserUtteranceHear) this.clientJustSetUserUtteranceHear = false;

            // Restart listening if needed
            this.isListeningForSpeech = false;
            if(this.continuousListening){
                //this.lastRecognitionError = '';
                let restart = true;
                if(this.clientStoppedListening){
                    restart = false;
                }
                //console.log('[va] should we restart listen? restart: ',restart,'. clientStoppedListening: ',this.clientStoppedListening);
                if(restart){
                    // restart listening without emitting start events to client
                    this.#restartListenOnNoSpeech();
                } else {
                    this.#emitEvent('onListenEnd',{});    
                }
            }else{
                this.#emitEvent('onListenEnd',{});
            }
        };

    }

    #destroySpeechRecognition(){

        if (this.recognition) {
            try {
              this.recognition.onresult = null;
              this.recognition.onerror = null;
              this.recognition.onend = null;
              this.recognition.onstart = null;
              this.recognition.stop(); // stop recognition before discarding
            } catch (e) {
              console.log('[va] Error while stopping recognition:', e);
            }
            this.recognition = null;
        }

    }


    #estimateDuration(text, rate = 1, baseWPM = 180) {
        const wordCount = text.trim().split(/\s+/).length;
        return (wordCount / baseWPM) * (1 / rate) * 60 * 1000 * 1.5;
        //return durationInSeconds.toFixed(2);
    }

    #estimateDuration_OLD(text, rate) {
        const words = text.trim().split(/\s+/).length;
        const avgWordsPerMinute = 180; // At rate 1.0
        const wps = (avgWordsPerMinute / 60) * rate;
        return (words / wps) * 1000; // milliseconds
    }

    #restartListenOnNoSpeech() {

        // Used primarily if no-speech occurs in recognition and we need to keep listener alive (without emitting events)

        //this.#initSpeechRecognition(); // should stop any current listening first
        if(!this.recognition){
            console.log('[va] restartListenOnNoSpeech(). recognition not active. abort');
            return;
        }

        if(!this.isListeningForSpeech){
            if(this.lastListenerRestart){
                const now = performance.now();
                if(now - this.lastListenerRestart < 1000){ // ms compare
                    // has restarted recently, could be in loop, stop listening
                    console.log('[va] Error restarting listener. Recommend shutdown any other Browser Tabs with microphones on and try again.');
                    this.lastListenerRestart = null;
                    this.stopListening(true); // simulate user stop listening to prevent auto restart
                    return;
                }
            }

            this.lastListenerRestart = performance.now();
            this.isRestartingListener = true;
            this.recognition.start(); // tiggers onstart event
        }
    }

    // Speak the next sentence in the queue
    #speakNextInAgentSpeechQueue(firstUtter = true) {
        // Stop if queue is empty, agent already finished speaking by now
        if (this.agentSpeechQueue.length === 0) {
            console.log('[va] Queue is empty, emit onAgentSpeechEnd event');
            this.isProcessingAgentSpeechQueue = false;
            this.isAgentSpeaking = false;
            this.agentJustFinishedSpeaking = true;
            this.#emitEvent('onAgentSpeechEnd',{});
            if(this.continuousListening && !this.isListeningForSpeech && !this.clientStoppedListening) this.listen(false);
            return;
        }
        
        // Get next sentence
        const sentence = this.agentSpeechQueue.shift(); // will reduce queue by 1
        console.log(`[va] Agent speak ${firstUtter ? 'first' : 'next'} sentence: "${sentence}". ${this.agentSpeechQueue.length} left in queue`);
        this.#emitEvent('onAgentSpeechSentence',{sentence: sentence, numSentencesRemaining: this.agentSpeechQueue.length});
        this.#startUtter(sentence, firstUtter);
    }

    #startUtter(text, firstUtter = true){

        // Create utterance and speak text

        this.utter = new window.SpeechSynthesisUtterance(text);

        this.#clearAgentSpeechTimeout();

        this.utter.onstart = () => {
            console.log('[va] Agent Speech onstart');
            this.isAgentSpeechEventsWorkingProp = true;
            this.#clearAgentSpeechTimeout();
            this.isAgentSpeaking = true;
            if(firstUtter){
                this.stopRecognitionDuringAgentSpeech && this.stopListening(false);
                this.#emitEvent('onAgentSpeechStart',{text: text});
            }
            //!this.allowAgentInterrupt && this.isListeningForSpeech && this.stopListening();  Maybe for Android??
        };
        this.utter.onend = () => {
            console.log('[va] Agent Speech onend');
            this.isAgentSpeechEventsWorkingProp = true;
            this.#clearAgentSpeechTimeout();
            //console.log(`[va] Agent Speech onend. Processing Queue: ${this.isProcessingAgentSpeechQueue ? 'true':'false'}. isAgentSpeaking: ${this.isAgentSpeaking ? 'true':'false'}`);
            if(this.isProcessingAgentSpeechQueue){
                this.#speakNextInAgentSpeechQueue(false);
            }else{
                console.log('[va] Agent Speech ended');
                this.isAgentSpeaking = false;
                //this.clientWantsToIgnoreUserUtterances = false;
                this.#emitEvent('onAgentSpeechEnd',{});
                // start up listening if not already and continuous
                if(this.continuousListening && !this.isListeningForSpeech && !this.clientStoppedListening) this.listen(false);
            }
        };
        this.utter.onerror = (e) => {
            console.log('[va] Agent Speech onerror:', e.error);
            this.#clearAgentSpeechTimeout();
            if(this.isProcessingAgentSpeechQueue){
                this.#speakNextInAgentSpeechQueue(false);
            }else{
                this.isAgentSpeaking = false;
                this.#emitEvent('onAgentSpeechEnd',{});
                // start up listening if not already and continuous
                if(this.continuousListening && !this.isListeningForSpeech && !this.clientStoppedListening) this.listen(false);
            }
        };

        // Interim words spoken. Typically this only works for local service voices
        this.utter.onboundary = (event) => {
            this.isAgentSpeechEventsWorkingProp = true;
            this.#clearAgentSpeechTimeout();
            if (event.name === "word") {
              const text = this.utter.text.substring(event.charIndex, event.charIndex + event.charLength);
              //console.log('[va] Agent Speech interim:',text);
              this.#emitEvent('onAgentSpeechInterim',{text : text});
            }
        };   
        
        // SSML mark events
        this.utter.onmark = (event) => {
            //console.log('Reached mark:', event.name); // 'halfway'
        };

        // Assign selected voice
        if(this.systemVoice) this.utter.voice = this.systemVoice;

        this.utter.volume = this.voiceVolume; // force set volume in case Safari changes it automatically
        this.isAgentSpeaking = true;

        // Start watchdog timer if utterance events not working - previously determined
        if(!this.isAgentSpeechEventsWorkingProp){
            const watchdogTimeoutMs = (text === ' ' ? 1000 : this.#estimateDuration(text, this.utter.rate));
            console.log(`[va] Start Agent Speech Watchdog. Timeout in ${watchdogTimeoutMs} ms`);
            this.agentSpeechTimeout = setTimeout(() => {
                this.#handleAgentSpeechTimeout();
            }, watchdogTimeoutMs);
        } 

        // Start speaking
        try{
            window.speechSynthesis.speak(this.utter);
            this.hasSpokenYet = true;
        }catch(ex){
            console.log('[va] Agent Speech start error:',ex);
        }

    }

    #clearAgentSpeechTimeout(){
        if (this.agentSpeechTimeout) {
            clearTimeout(this.agentSpeechTimeout);
            this.agentSpeechTimeout = null;
        }
    }

    #handleAgentSpeechTimeout(){
        this.isAgentSpeechEventsWorkingProp = false;
        const text = this.utter ? this.utter.text : 'null';
        console.log(`[va] Agent Speech Timeout during text: "${text}"`);
        this.stopSpeaking(false);
    }

    #destroyUtter(){

        if (this.utter) {
            try {
              this.utter.onboundary = null;
              this.utter.onmark = null;
              this.utter.onerror = null;
              this.utter.onend = null;
              this.utter.onpause = null;
              this.utter.onresume = null;
              this.utter.onstart = null;
              window.speechSynthesis.cancel();
            } catch (e) {
              console.log('[va] Error while stopping agent speech:', e);
            }
            this.utter = null;
        }
        
    }

    #emitEvent(eventName, detail){
        const emitEvent = new CustomEvent(eventName, {detail: detail});
        this.eventTarget.dispatchEvent(emitEvent);
    }

    #clearUserSpeechGapTimer() {
        if (this.userSpeechGapTimer) {
            clearTimeout(this.userSpeechGapTimer);
            this.userSpeechGapTimer = null;
        }
    }

    #resetUserSpeechGapTimer(silenceTimeoutMs = 1500) {
        // the 1500 default ms is for after a user has already started speech, ie: only wait about 1.5 seconds of silence before stop
        this.#clearUserSpeechGapTimer();
        this.userSpeechGapTimer = setTimeout(() => {
            console.log(`[va] Speech gap detected after ${silenceTimeoutMs} ms`);
            if(this.continuousListening){
                if(this.finalSpeechTranscript){
                    this.ignoreInterimRecognitionResults = true;
                    if(this.clientWantsToIgnoreUserUtterances){
                        console.log('[va] heard final user utterance but ignoring as instructed');
                    }else{
                        console.log(`[va] Emit transcript:`,this.finalSpeechTranscript);
                        let transcript = `${this.finalSpeechTranscript}`;
                        this.finalSpeechTranscript = '';
                        this.#emitEvent('finalUserSpeechTranscribed',{transcript: transcript.trim()});
                    }
                }
            }else{
                console.log(`[va] Stop listening`);
            }
            this.recognition.stop(); // triggers onend event
        }, silenceTimeoutMs);
    }

    async #testMediaRecorder(){

        // Not sure if we can use this approach?? Suggested by ChatGPT

        const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        const mediaRecorder = new MediaRecorder(stream);

        mediaRecorder.ondataavailable = (e) => {
            const audioBlob = e.data;
            // send `audioBlob` to your server or a speech-to-text API
        };

        mediaRecorder.start();

        // You could chunk the microphone recording every X seconds and continuously send small pieces to be transcribed.
    }

    async #isMicrophoneAvailableInternal() {
        try {
            const devices = await navigator.mediaDevices.enumerateDevices();
            const microphone = devices.find(device => device.kind === 'audioinput');
            return microphone !== undefined; // Returns true if a microphone is found, false otherwise
        } catch (error) {
            console.log('[va] isMicrophoneAvailable() error:', error);
            return false; // Return false if there's an error
        }
    }

    async #setupVAD() {
        try {
            this.vad = new this.#VAD({
                energyThreshold: -75,  // Much more sensitive
                smoothingTimeConstant: 0.5,  // Faster response
                minVoiceFreq: 50,  // Wider range
                maxVoiceFreq: 400
            });

            if (await this.vad.init()) {
                this.vad.setCallback((voiceActive, energy) => {
                    //console.log(`[vad] callback. voiceActive: ${voiceActive}, energy:`,energy);
                    this.#handleVADVoiceActivity(voiceActive, energy);
                });
                console.log('[vad] initialized successfully');
                //this.lastVADVoiceActivity = Date.now(); // Initialize with current time
                return true;
            }
        } catch (error) {
            console.log('[vad] initialization failed:', error);
        }
        
        this.vad = null;
        return false;
    }

    #handleVADVoiceActivity(voiceActive, energy) {

        this.#emitEvent('voiceActivityDetected',{voiceActive: voiceActive, energy: Math.round(energy)});

        // if is User Speech, immediately stop VAD and start recognition
        this.vad.disconnect();

        //this.#initSpeechRecognition();
        if(!this.recognition){
            console.log('[va] handleVADVoiceActivity(). recognition not active. abort');
            return;
        }
        console.log('[va] handleVADVoiceActivity() Starting recognition...');
        this.recognition.start(); // tiggers onstart event



        //this.vadStatus.textContent = `Voice: ${voiceActive ? 'active' : 'inactive'} (${Math.round(energy)}dB)`;
        //this.vadStatus.classList.toggle('active', voiceActive);
        
        /*
        if (voiceActive) {
            this.lastVADVoiceActivity = Date.now();
            console.log('[vad] Voice activity detected:', Math.round(energy), 'dB');
            
            // Handle interruption
            if (this.isAgentSpeaking) {
                console.log('[vad] VAD detected voice during speech, energy:', Math.round(energy), 'dB');
                window.speechSynthesis.cancel();
                this.isAgentSpeaking = false; // may not need this if events handle
            }
        }
        */
    }

    // Voice Activity Detection implementation from https://github.com/Jam3/voice-activity-detection
    // Android usage
    #VAD = class {
        constructor(options = {}) {
            this.bufferSize = options.bufferSize || 2048;
            this.smoothingTimeConstant = options.smoothingTimeConstant || 0.99;
            this.energyThreshold = options.energyThreshold || -50;
            this.minCaptureFreq = options.minCaptureFreq || 85;  // in Hz
            this.maxCaptureFreq = options.maxCaptureFreq || 255; // in Hz
            this.minVoiceFreq = options.minVoiceFreq || 85;      // in Hz
            this.maxVoiceFreq = options.maxVoiceFreq || 255;     // in Hz

            this.audioContext = null;
            this.microphone = null;
            this.analyser = null;
            this.frequencyBins = null;
            this.vadState = false;
            this.callback = null;
            this.isRunning = false;
            this.stream = null;
        }

        async init() {
            try {
                this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
                this.stream = await navigator.mediaDevices.getUserMedia({ 
                    audio: {
                        echoCancellation: true,
                        noiseSuppression: true,
                        autoGainControl: true
                    } 
                });
                
                this.microphone = this.audioContext.createMediaStreamSource(this.stream);
                this.analyser = this.audioContext.createAnalyser();
                this.analyser.smoothingTimeConstant = this.smoothingTimeConstant;
                this.analyser.fftSize = this.bufferSize;

                this.frequencyBins = new Float32Array(this.analyser.frequencyBinCount);
                this.microphone.connect(this.analyser);
                
                console.log('[vad] audio setup complete');
                return true;
            } catch (error) {
                console.log('[vad] initialization failed:', error);
                this.cleanup();
                return false;
            }
        }

        setCallback(callback) {
            this.callback = callback;
            if (!this.isRunning) {
                this.isRunning = true;
                this.loop();
            }
        }

        loop() {
            if (!this.isRunning || !this.analyser || !this.audioContext) {
                console.log('[vad] stopping');
                return;
            }

            try {
                this.analyser.getFloatFrequencyData(this.frequencyBins);
                
                let energy = 0;
                let signalFrequencies = 0;
                let voiceFrequencies = 0;

                // Calculate energy in signal frequencies
                for (let i = 0; i < this.frequencyBins.length; i++) {
                    const frequency = i * this.audioContext.sampleRate / this.bufferSize;
                    if (frequency >= this.minCaptureFreq && frequency <= this.maxCaptureFreq) {
                        energy += Math.pow(10, this.frequencyBins[i] / 10);
                        signalFrequencies++;

                        if (frequency >= this.minVoiceFreq && frequency <= this.maxVoiceFreq) {
                            voiceFrequencies++;
                        }
                    }
                }

                const meanEnergy = energy / (signalFrequencies || 1); // Avoid division by zero
                const dbEnergy = 10 * Math.log10(meanEnergy || 1e-10); // Avoid log of 0
                const voicePresent = dbEnergy > this.energyThreshold && voiceFrequencies > 0;

                // State changed
                if (voicePresent !== this.vadState) {
                    this.vadState = voicePresent;
                    this.callback?.(this.vadState, dbEnergy);
                }

                // Continue loop
                if (this.isRunning) {
                    requestAnimationFrame(() => this.loop());
                }
            } catch (error) {
                console.log('[vad] processing error:', error);
                this.cleanup();
            }
        }

        setEnergyThreshold(threshold) { 
            this.energyThreshold = threshold;
        }

        getEnergyThreshold() {
            return this.energyThreshold;
        }

        cleanup() {
            this.isRunning = false;
            
            if (this.microphone) {
                this.microphone.disconnect();
                this.microphone = null;
            }

            if (this.stream) {
                this.stream.getTracks().forEach(track => track.stop());
                this.stream = null;
            }

            if (this.analyser) {
                this.analyser.disconnect();
                this.analyser = null;
            }

            if (this.audioContext && this.audioContext.state !== 'closed') {
                this.audioContext.close();
            }
            this.audioContext = null;
            this.frequencyBins = null;
        }

        disconnect() {
            this.cleanup();
        }
    }

}

window.TWXVoiceAgent = TWXVoiceAgent;