Source: avatar/bot-controller.js

import { AvatarAnimation } from './avatar-animation.js';
import { WorldManager } from '../core/world-manager.js';
import { Avatar } from './avatar.js';

/**
 * Additional processing of Bot events: voice synthesis and animations.
 */
export class BotController {
  /** Synthesis can be iritating, enabled by default */
  static speechSynthesisEnabled = true;
  /**
   * @param {Avatar} avatar 
   */
  constructor(avatar) {
    /** Timestamp of last change */
    this.lastChange = Date.now();
    /** After not receiving any events for this many millis, idle animation starts */
    this.idleTimeout = 200;
    this.lastAnimation = null;
    this.worldManager = WorldManager.instance;
    this.world = this.worldManager.world;
    this.scene = this.worldManager.scene;
    this.avatar = avatar;
    this.vrObject = avatar.VRObject;
    this.animation = new AvatarAnimation(avatar);
    this.animation.improvise = false;
    this.setupIdleTimer();
    this.vrObject.addListener((obj, changes) => this.processChanges(obj, changes));
    this.animationEnd = (animation) => this.animationEnded(animation);
    this.voice = null;

    const voices = window.speechSynthesis.getVoices();
    if (voices.length == 0) {
      // chrome fires event
      window.speechSynthesis.onvoiceschanged = (changed) => {
        this.processVoices(window.speechSynthesis.getVoices());
      }
    } else {
      // mozilla returns it right away
      this.processVoices(voices);
    }
  }

  processVoices(voices) {
    console.log("voices", voices);
    this.voice = voices[0];
    let langMatch = this.vrObject.lang == null; // null means any matches
    let genderMatch = this.vrObject.gender == null; // null means any matches
    voices.forEach((voice, index) => {
      // CHECKME this could be easily wrong way to select female voice
      if (!langMatch && this.vrObject.lang && voice.lang == this.vrObject.lang) {
        langMatch = true;
        this.voice = voice;
      }
      let female = voice.name.indexOf("Zira") >= 0 || voice.name.indexOf("Female") >= 0;
      if (!genderMatch && female && this.vrObject.gender && this.vrObject.gender.toLowerCase() === "female") {
        genderMatch = true;
        this.voice = voice;
      }
    });
    console.log("Voice selected", this.voice);
  }

  /**
   * Create timer for idle animation, if it doesn't exist.
   * CHECKME copied from AvatarController
   */
  setupIdleTimer() {
    if (this.idleTimerId) {
      return;
    }
    this.idleTimerId = setInterval(() => {
      if (this.worldManager.isOnline() && Date.now() - this.lastChange > this.idleTimeout) {
        clearInterval(this.idleTimerId);
        this.idleTimerId = null;
        this.startAnimation(this.animation.idle().group, true);
      }
    }, this.idleTimeout);
  }

  startAnimation(animation, loop = false) {
    if (animation && this.animation.contains(animation.name) && animation.name != this.lastAnimation) {
      this.avatar.startAnimation(animation.name, loop);
      this.lastAnimation = animation.name;
    }
  }

  animationEnded(animation) {
    //console.log("Animation ended", animation);
    this.setupIdleTimer();
    animation.onAnimationGroupEndObservable.remove(this.animationEnd);
  }
  
  processChanges(obj, changes) {
    //console.log("processing changes ",obj,changes);
    if (changes['wrote']) {
      if (this.voice && BotController.speechSynthesisEnabled) {
        if (window.speechSynthesis.pending || window.speechSynthesis.speaking) {
          console.log("Interrupting speech");
          window.speechSynthesis.cancel();
        }
        const utter = new SpeechSynthesisUtterance(changes.wrote);
        utter.voice = this.voice;
        window.speechSynthesis.speak(utter);
      }
      let animation = this.animation.processText(changes.wrote);
      if (animation) {
        animation.onAnimationGroupEndObservable.add(this.animationEnd);
        this.startAnimation(animation);
      }
    }
    if (changes['animation']) {
      // animation is already playing
      let group = this.avatar.getAnimation(changes['animation'].name);
      this.lastAnimation = group.name;
      group.onAnimationGroupEndObservable.add(this.animationEnd);
    }
  }
}