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("Searching voices for " + this.vrObject.lang + " " + this.vrObject.gender, 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) => {
      if (this.vrObject.lang) {
        // android uses underscore, other devices dash
        let androidLang = this.vrObject.lang.replace("-", "_");
        let webLang = this.vrObject.lang.replace("_", "-");
        if (voice.lang.startsWith(androidLang) || voice.lang.startsWith(webLang)) {
          langMatch = true;
          // first voice matching the language is taken by default
          if (!this.voice) {
            this.voice = voice;
          }
        }
      }
      if (this.vrObject.gender) {
        let gender = this.vrObject.gender.toLowerCase();
        if (gender === "female" && voice.name.indexOf("Zira") >= 0 || voice.name.indexOf("Female") >= 0) {
          genderMatch = true;
        } else if (gender === "male" && voice.name.indexOf("David") >= 0 || voice.name.indexOf("Male") >= 0) {
          genderMatch = true;
        }
      }
      if (langMatch && genderMatch) {
        this.voice = voice;
        langMatch = false;
        genderMatch = false;
      }
    });
    if (!this.voice && voices.length > 0) {
      this.voice = voices[0];
    }
    console.log("Voice selected for " + this.avatar.name, 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 for "+this.avatar.name,obj,changes);
    if (changes['wrote']) {
      //console.log("processing speech "+this.avatar.name);
      let text = changes.wrote.text;
      if (this.voice && BotController.speechSynthesisEnabled) {
        if (window.speechSynthesis.pending || window.speechSynthesis.speaking) {
          console.log("Interrupting speech");
          window.speechSynthesis.cancel();
        }
        const utter = new SpeechSynthesisUtterance(text);
        console.log("Speaking " + this.avatar.name, this.voice);
        utter.voice = this.voice;
        window.speechSynthesis.speak(utter);
      }
      let animation = this.animation.processText(text);
      if (animation) {
        animation.onAnimationGroupEndObservable.add(this.animationEnd);
        this.startAnimation(animation);
      }
    }
    if (changes['animation']) {
      let animation = changes['animation'].name;
      // animation is already playing
      let group = this.avatar.getAnimation(animation);
      if (group) {
        this.lastAnimation = group.name;
        group.onAnimationGroupEndObservable.add(this.animationEnd);
      } else {
        console.log(this.avatar.name + " tried to play non existing animation " + animation);
      }
    }
  }
}