Source: src/tts.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const Readable = require('stream').Readable;
const tts = require('@google-cloud/text-to-speech');
require('./subModule.js').extend(TTS);  // Extends the SubModule class.

const ttsRequest = {
  input: {text: 'Hello world!'},
  voice: {languageCode: 'en-AU', ssmlGender: 'MALE'},
  audioConfig: {audioEncoding: 'OGG_OPUS'},
};

/**
 * @classdesc Adds text-to-speech support for voice channels.
 * @class
 * @augments SubModule
 * @listens Command#tts
 * @listens Command#speak
 */
function TTS() {
  const self = this;
  /** @inheritdoc */
  this.myName = 'TTS';

  /** @inheritdoc */
  this.helpMessage = null;

  /** @inheritdoc */
  this.initialize = function() {
    self.command.on(['tts', 'speak'], commandTTS, true);
    if (self.bot.getGoalSubModules &&
        !self.bot.getGoalSubModules().includes('./music.js')) {
      self.command.on(['leave', 'stop', 'stfu'], commandLeave, true);
    }

    if (self.bot.getBotName()) {
      process.env.GOOGLE_APPLICATION_CREDENTIALS =
          './gApiCredentials-' + self.bot.getBotName() + '.json';
    } else {
      process.env.GOOGLE_APPLICATION_CREDENTIALS = './gApiCredentials.json';
    }
    ttsClient = new tts.TextToSpeechClient();
  };
  /** @inheritdoc */
  this.shutdown = function() {
    self.command.deleteEvent('tts');
    self.command.deleteEvent('leave');
  };

  let ttsClient;

  /**
   * The permission required to use TTS commands.
   *
   * @private
   * @constant
   * @default
   * @type {string}
   */
  const ttsPermString = 'tts:all';

  /**
   * Joins a user's voice channel and speaks the given message.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#tts
   * @listens Command#speak
   */
  function commandTTS(msg) {
    self.bot.patreon.checkAllPerms(
        msg.author.id, msg.channel.id, msg.guild.id, ttsPermString, onGetPerms);

    /**
     * Callback for checking permissions for command.
     *
     * @private
     * @type {Patreon~basicCB}
     * @param {?string} err The error string, or null if no error.
     * @param {?{status: boolean, message: string}} info The returned data if
     * there was no error.
     */
    function onGetPerms(err, info) {
      if (err) {
        self.common.reply(
            msg, 'Oops! I wasn\'t able to do that for you...', err);
        return;
      } else if (!info.status) {
        self.common.reply(
            msg, 'Sorry, but you aren\'t able to use this command.',
            info.message);
        return;
      }
      self.bot.patreon.getSettingValue(
          msg.author.id, msg.channel.id, msg.guild.id, ttsPermString,
          onGetSettings);
    }

    let matchedSettings;

    /**
     * After checking if a user has permission for this command, send the
     * request too Google with the user's settings.
     *
     * @private
     * @type {Patreon~basicCB}
     * @param {?string} err The error string, or null if no error.
     * @param {?{status: string, message: string}} info The returned data if
     * there was no error.
     */
    function onGetSettings(err, info) {
      if (err || !info.status) {
        self.common.reply(
            msg,
            'Oops! Something went wrong while looking for your settings...',
            err || 'Received NULL');
        self.error(
            'Failed to fetch settings for tts:all: ' + msg.author.id + ' (' +
            (err || 'Received NULL') + ')');
        return;
      }
      matchedSettings = info.status.match(/(\w\w-\w\w)-([MF])/);
      if (!matchedSettings) {
        self.common.reply(
            msg, 'Oops! Something went wrong while reading your settings...',
            'Invalid Value');
        self.error(
            'User has invalid setting for tts:all: ' + msg.author.id + ' (' +
            info.status + ')');
        return;
      }
      if (!msg.member.voice || !msg.member.voice.channel) {
        self.common.reply(
            msg, 'Oops! You must be in a voice channel for this command.');
        return;
      }
      if (msg.member.voice.channel.connection) {
        onJoinVoice(msg.member.voice.channel.connection);
      } else {
        msg.member.voice.channel.join().then(onJoinVoice).catch(() => {
          self.common.reply(
              msg, 'Oops! I wasn\'t able to join your voice channel.');
          return;
        });
      }
    }

    let vConn;
    /**
     * @description Successfully joined a voice channel, now we can request
     * audio data from Google.
     *
     * @private
     * @param {Discord~VoiceConnection} conn The voice channel connection.
     */
    function onJoinVoice(conn) {
      if (msg.text.length <= 1) {
        self.common.reply(
            msg, 'Please specify what you want me to say after the command.');
        return;
      }
      vConn = conn;
      const thisRequest = Object.assign({}, ttsRequest);
      thisRequest.input.text = msg.text.slice(1);
      thisRequest.voice.languageCode = matchedSettings[1];
      thisRequest.voice.ssmlGender =
          matchedSettings[2] == 'F' ? 'FEMALE' : 'MALE';
      ttsClient.synthesizeSpeech(thisRequest, onSpeechResponse);
    }

    /**
     * Response from Google with TTS audio data.
     *
     * @private
     * @param {?Error} err Errors in request.
     * @param {?object} res Response.
     */
    function onSpeechResponse(err, res) {
      if (err) {
        self.common.reply(
            msg, 'Oops! Google wasn\'t able to turn that into audio...');
        self.error('Google failed to create audio data.');
        console.error(err);
        return;
      }
      self.common.reply(
          msg, 'Saying "' + msg.text.slice(1) + '" in ' +
              msg.member.voice.channel.name);
      const readable = new Readable();
      readable._read = function() {};
      vConn.play(readable);
      readable.push(res.audioContent);
    }
  }

  /**
   * Cause the bot to leave the voice channel.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#leave
   * @listens Command#stfu
   * @listens Command#stop
   */
  function commandLeave(msg) {
    if (msg.guild.me.voice.channel) msg.guild.me.voice.channel.leave();
  }
}
module.exports = new TTS();