// 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();