From be32bbc3a401869d36e80f37fb4601aeff0b9cd1 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 17:04:18 +0000 Subject: [PATCH 01/53] really really really messy implementation of prism --- src/client/voice/VoiceConnection.js | 6 ++ .../voice/dispatcher/StreamDispatcher.js | 13 +-- src/client/voice/pcm/ConverterEngine.js | 14 --- src/client/voice/pcm/ConverterEngineList.js | 1 - src/client/voice/pcm/FfmpegConverterEngine.js | 86 --------------- src/client/voice/player/AudioPlayer.js | 85 ++++++--------- src/client/voice/player/AudioPlayer.old.js | 100 ++++++++++++++++++ .../{BasePlayer.js => BasePlayer.old.js} | 0 ...{DefaultPlayer.js => DefaultPlayer.old.js} | 0 9 files changed, 145 insertions(+), 160 deletions(-) delete mode 100644 src/client/voice/pcm/ConverterEngine.js delete mode 100644 src/client/voice/pcm/ConverterEngineList.js delete mode 100644 src/client/voice/pcm/FfmpegConverterEngine.js create mode 100644 src/client/voice/player/AudioPlayer.old.js rename src/client/voice/player/{BasePlayer.js => BasePlayer.old.js} (100%) rename src/client/voice/player/{DefaultPlayer.js => DefaultPlayer.old.js} (100%) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index ac44ff866..08cc1f924 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -5,6 +5,7 @@ const AudioPlayer = require('./player/AudioPlayer'); const VoiceReceiver = require('./receiver/VoiceReceiver'); const EventEmitter = require('events').EventEmitter; const fs = require('fs'); +const Prism = require('prism-media'); /** * Represents a connection to a voice channel in Discord. @@ -26,6 +27,11 @@ class VoiceConnection extends EventEmitter { */ this.voiceManager = pendingConnection.voiceManager; + /** + * The audio transcoder for this connection + */ + this.prism = new Prism(); + /** * The voice channel this connection is currently serving * @type {VoiceChannel} diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index e08a36537..a56a4a98e 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -16,17 +16,10 @@ nonce.fill(0); * @extends {EventEmitter} */ class StreamDispatcher extends EventEmitter { - constructor(player, stream, sd, streamOptions) { + constructor(player, stream, streamOptions) { super(); this.player = player; this.stream = stream; - this.streamingData = { - channels: 2, - count: 0, - sequence: sd.sequence, - timestamp: sd.timestamp, - pausedTime: 0, - }; this._startStreaming(); this._triggered = false; this._volume = streamOptions.volume; @@ -47,6 +40,10 @@ class StreamDispatcher extends EventEmitter { this.setVolume(streamOptions.volume || 1); } + get streamingData() { + return this.player.streamingData; + } + /** * How long the stream dispatcher has been "speaking" for * @type {number} diff --git a/src/client/voice/pcm/ConverterEngine.js b/src/client/voice/pcm/ConverterEngine.js deleted file mode 100644 index 6b7502f90..000000000 --- a/src/client/voice/pcm/ConverterEngine.js +++ /dev/null @@ -1,14 +0,0 @@ -const EventEmitter = require('events').EventEmitter; - -class ConverterEngine extends EventEmitter { - constructor(player) { - super(); - this.player = player; - } - - createConvertStream() { - return; - } -} - -module.exports = ConverterEngine; diff --git a/src/client/voice/pcm/ConverterEngineList.js b/src/client/voice/pcm/ConverterEngineList.js deleted file mode 100644 index 56d430e48..000000000 --- a/src/client/voice/pcm/ConverterEngineList.js +++ /dev/null @@ -1 +0,0 @@ -exports.fetch = () => require('./FfmpegConverterEngine'); diff --git a/src/client/voice/pcm/FfmpegConverterEngine.js b/src/client/voice/pcm/FfmpegConverterEngine.js deleted file mode 100644 index 8fb725bda..000000000 --- a/src/client/voice/pcm/FfmpegConverterEngine.js +++ /dev/null @@ -1,86 +0,0 @@ -const ConverterEngine = require('./ConverterEngine'); -const ChildProcess = require('child_process'); -const EventEmitter = require('events').EventEmitter; - -class PCMConversionProcess extends EventEmitter { - constructor(process) { - super(); - this.process = process; - this.input = null; - this.process.on('error', e => this.emit('error', e)); - } - - setInput(stream) { - this.input = stream; - stream.pipe(this.process.stdin, { end: false }); - this.input.on('error', e => this.emit('error', e)); - this.process.stdin.on('error', e => this.emit('error', e)); - } - - destroy() { - this.emit('debug', 'destroying a ffmpeg process:'); - if (this.input && this.input.unpipe && this.process.stdin) { - this.input.unpipe(this.process.stdin); - this.emit('unpiped the user input stream from the process input stream'); - } - if (this.process.stdin) { - this.process.stdin.end(); - this.emit('ended the process stdin'); - } - if (this.process.stdin.destroy) { - this.process.stdin.destroy(); - this.emit('destroyed the process stdin'); - } - if (this.process.kill) { - this.process.kill(); - this.emit('killed the process'); - } - } - -} - -class FfmpegConverterEngine extends ConverterEngine { - constructor(player) { - super(player); - this.command = chooseCommand(); - } - - handleError(encoder, err) { - if (encoder.destroy) encoder.destroy(); - this.emit('error', err); - } - - createConvertStream(seek = 0) { - super.createConvertStream(); - const encoder = ChildProcess.spawn(this.command, [ - '-analyzeduration', '0', - '-loglevel', '0', - '-i', '-', - '-f', 's16le', - '-ar', '48000', - '-ac', '2', - '-ss', String(seek), - 'pipe:1', - ], { stdio: ['pipe', 'pipe', 'ignore'] }); - return new PCMConversionProcess(encoder); - } -} - -function chooseCommand() { - for (const cmd of [ - 'ffmpeg', - 'avconv', - './ffmpeg', - './avconv', - 'node_modules\\ffmpeg-binaries\\bin\\ffmpeg', - 'node_modules/ffmpeg-binaries/bin/ffmpeg', - ]) { - if (!ChildProcess.spawnSync(cmd, ['-h']).error) return cmd; - } - throw new Error( - 'FFMPEG was not found on your system, so audio cannot be played. ' + - 'Please make sure FFMPEG is installed and in your PATH.' - ); -} - -module.exports = FfmpegConverterEngine; diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 96c6c24ae..64c54630f 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -1,30 +1,24 @@ -const PCMConverters = require('../pcm/ConverterEngineList'); -const OpusEncoders = require('../opus/OpusEngineList'); const EventEmitter = require('events').EventEmitter; +const Prism = require('prism-media'); const StreamDispatcher = require('../dispatcher/StreamDispatcher'); +const Collection = require('../../../util/Collection'); +const OpusEncoders = require('../opus/OpusEngineList'); + +const ffmpegArguments = [ + '-analyzeduration', '0', + '-loglevel', '0', + '-f', 's16le', + '-ar', '48000', + '-ac', '2', +]; -/** - * Represents the Audio Player of a Voice Connection - * @extends {EventEmitter} - * @private - */ class AudioPlayer extends EventEmitter { constructor(voiceConnection) { super(); - /** - * The voice connection the player belongs to - * @type {VoiceConnection} - */ this.voiceConnection = voiceConnection; - this.audioToPCM = new (PCMConverters.fetch())(); + this.prism = new Prism(); this.opusEncoder = OpusEncoders.fetch(); - this.currentConverter = null; - /** - * The current stream dispatcher, if a stream is being played - * @type {StreamDispatcher} - */ - this.dispatcher = null; - this.audioToPCM.on('error', e => this.emit('error', e)); + this.transcoders = new Collection(); this.streamingData = { channels: 2, count: 0, @@ -32,49 +26,38 @@ class AudioPlayer extends EventEmitter { timestamp: 0, pausedTime: 0, }; - this.voiceConnection.on('closing', () => this.cleanup(null, 'voice connection closing')); + } + + get currentTranscoder() { + return this.transcoders.last(); + } + + destroyAllTranscoders(exceptLatest) { + for (const stream of this.transcoders.keys()) { + const transcoder = this.transcoders.get(stream); + if (exceptLatest && transcoder === this.currentTranscoder) continue; + transcoder.kill(); + } } playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - stream.on('end', () => { - this.emit('debug', 'Input stream to converter has ended'); + const transcoder = this.prism.transcode({ + type: 'ffmpeg', + media: stream, + ffmpegArguments, }); - stream.on('error', e => this.emit('error', e)); - const conversionProcess = this.audioToPCM.createConvertStream(options.seek); - conversionProcess.on('error', e => this.emit('error', e)); - conversionProcess.setInput(stream); - return this.playPCMStream(conversionProcess.process.stdout, conversionProcess, options); + this.transcoders.set(stream, transcoder); + this.playPCMStream(transcoder.output, options); } - cleanup(checkStream, reason) { - // cleanup is a lot less aggressive than v9 because it doesn't try to kill every single stream it is aware of - this.emit('debug', `Clean up triggered due to ${reason}`); - const filter = checkStream && this.dispatcher && this.dispatcher.stream === checkStream; - if (this.currentConverter && (checkStream ? filter : true)) { - this.currentConverter.destroy(); - this.currentConverter = null; - } - } - - playPCMStream(stream, converter, { seek = 0, volume = 1, passes = 1 } = {}) { + playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - stream.on('end', () => this.emit('debug', 'PCM input stream ended')); - this.cleanup(null, 'outstanding play stream'); - this.currentConverter = converter; - if (this.dispatcher) { - this.streamingData = this.dispatcher.streamingData; - } - stream.on('error', e => this.emit('error', e)); - const dispatcher = new StreamDispatcher(this, stream, this.streamingData, options); - dispatcher.on('error', e => this.emit('error', e)); - dispatcher.on('end', () => this.cleanup(dispatcher.stream, 'dispatcher ended')); + this.destroyAllTranscoders(true); + const dispatcher = new StreamDispatcher(this, stream, options); dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); - this.dispatcher = dispatcher; - dispatcher.on('debug', m => this.emit('debug', `Stream dispatch - ${m}`)); return dispatcher; } - } module.exports = AudioPlayer; diff --git a/src/client/voice/player/AudioPlayer.old.js b/src/client/voice/player/AudioPlayer.old.js new file mode 100644 index 000000000..7e9ce616b --- /dev/null +++ b/src/client/voice/player/AudioPlayer.old.js @@ -0,0 +1,100 @@ +const OpusEncoders = require('../opus/OpusEngineList'); +const EventEmitter = require('events').EventEmitter; +const StreamDispatcher = require('../dispatcher/StreamDispatcher'); + +const ffmpegArguments = [ + '-analyzeduration', '0', + '-loglevel', '0', + '-f', 's16le', + '-ar', '48000', + '-ac', '2', +]; + +/** + * Represents the Audio Player of a Voice Connection + * @extends {EventEmitter} + * @private + */ +class AudioPlayer extends EventEmitter { + constructor(voiceConnection) { + super(); + /** + * The voice connection the player belongs to + * @type {VoiceConnection} + */ + this.voiceConnection = voiceConnection; + this.opusEncoder = OpusEncoders.fetch(); + this.currentConverter = null; + /** + * The current stream dispatcher, if a stream is being played + * @type {StreamDispatcher} + */ + this.dispatcher = null; + // this.prism.on('error', e => this.emit('error', e)); + this.streamingData = { + channels: 2, + count: 0, + sequence: 0, + timestamp: 0, + pausedTime: 0, + }; + this.voiceConnection.on('closing', () => this.cleanup(null, 'voice connection closing')); + } + + get prism() { + return this.voiceConnection.prism; + } + + playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + const transcoder = this.prism.transcode({ + type: 'ffmpeg', + media: stream, + ffmpegArguments, + }); + this.playPCMStream(transcoder.output, options); + } + + /*playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + stream.on('end', () => { + this.emit('debug', 'Input stream to converter has ended'); + }); + stream.on('error', e => this.emit('error', e)); + const conversionProcess = this.audioToPCM.createConvertStream(options.seek); + conversionProcess.on('error', e => this.emit('error', e)); + conversionProcess.setInput(stream); + return this.playPCMStream(conversionProcess.process.stdout, conversionProcess, options); +}*/ + + cleanup(checkStream, reason) { + // cleanup is a lot less aggressive than v9 because it doesn't try to kill every single stream it is aware of + this.emit('debug', `Clean up triggered due to ${reason}`); + const filter = checkStream && this.dispatcher && this.dispatcher.stream === checkStream; + if (this.currentConverter && (checkStream ? filter : true)) { + this.currentConverter.destroy(); + this.currentConverter = null; + } + } + + playPCMStream(stream, converter, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + stream.on('end', () => this.emit('debug', 'PCM input stream ended')); + this.cleanup(null, 'outstanding play stream'); + this.currentConverter = converter; + if (this.dispatcher) { + this.streamingData = this.dispatcher.streamingData; + } + stream.on('error', e => this.emit('error', e)); + const dispatcher = new StreamDispatcher(this, stream, this.streamingData, options); + dispatcher.on('error', e => this.emit('error', e)); + dispatcher.on('end', () => this.cleanup(dispatcher.stream, 'dispatcher ended')); + dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + this.dispatcher = dispatcher; + dispatcher.on('debug', m => this.emit('debug', `Stream dispatch - ${m}`)); + return dispatcher; + } + +} + +module.exports = AudioPlayer; diff --git a/src/client/voice/player/BasePlayer.js b/src/client/voice/player/BasePlayer.old.js similarity index 100% rename from src/client/voice/player/BasePlayer.js rename to src/client/voice/player/BasePlayer.old.js diff --git a/src/client/voice/player/DefaultPlayer.js b/src/client/voice/player/DefaultPlayer.old.js similarity index 100% rename from src/client/voice/player/DefaultPlayer.js rename to src/client/voice/player/DefaultPlayer.old.js From 8cf520d5affa16ea56d1aad533076b865edde5ec Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 17:28:14 +0000 Subject: [PATCH 02/53] update streamDispatcher --- .../voice/dispatcher/StreamDispatcher.js | 138 +++----- .../voice/dispatcher/StreamDispatcher.old.js | 304 ++++++++++++++++++ src/client/voice/player/AudioPlayer.js | 21 +- 3 files changed, 362 insertions(+), 101 deletions(-) create mode 100644 src/client/voice/dispatcher/StreamDispatcher.old.js diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index a56a4a98e..6e3cfd207 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -20,16 +20,9 @@ class StreamDispatcher extends EventEmitter { super(); this.player = player; this.stream = stream; - this._startStreaming(); - this._triggered = false; - this._volume = streamOptions.volume; - - /** - * How many passes the dispatcher should take when sending packets to reduce packet loss. Values over 5 - * aren't recommended, as it means you are using 5x more bandwidth. You _can_ edit this at runtime. - * @type {number} - */ - this.passes = streamOptions.passes || 1; + this.startStreaming(); + this.streamOptions = streamOptions; + this.streamOptions.volume = this.streamOptions.volume || 0; /** * Whether playing is paused @@ -37,9 +30,15 @@ class StreamDispatcher extends EventEmitter { */ this.paused = false; + this.destroyed = false; + this.setVolume(streamOptions.volume || 1); } + get passes() { + return this.streamOptions.passes || 1; + } + get streamingData() { return this.player.streamingData; } @@ -68,7 +67,7 @@ class StreamDispatcher extends EventEmitter { * @readonly */ get volume() { - return this._volume; + return this.streamOptions.volume; } /** @@ -76,7 +75,7 @@ class StreamDispatcher extends EventEmitter { * @param {number} volume The volume that you want to set */ setVolume(volume) { - this._volume = volume; + this.streamOptions.volume = volume; } /** @@ -84,7 +83,7 @@ class StreamDispatcher extends EventEmitter { * @param {number} db The decibels */ setVolumeDecibels(db) { - this._volume = Math.pow(10, db / 20); + this.streamOptions.volume = Math.pow(10, db / 20); } /** @@ -92,32 +91,29 @@ class StreamDispatcher extends EventEmitter { * @param {number} value The value for the volume */ setVolumeLogarithmic(value) { - this._volume = Math.pow(value, 1.660964); + this.streamOptions.volume = Math.pow(value, 1.660964); } /** * Stops sending voice packets to the voice connection (stream may still progress however) */ - pause() { - this._setPaused(true); - } + pause() { this.setPaused(true); } /** * Resumes sending voice packets to the voice connection (may be further on in the stream than when paused) */ - resume() { - this._setPaused(false); - } + resume() { this.setPaused(false); } + /** * Stops the current stream permanently and emits an `end` event. * @param {string} [reason='user'] An optional reason for stopping the dispatcher. */ end(reason = 'user') { - this._triggerTerminalState('end', reason); + this.destroy('end', reason); } - _setSpeaking(value) { + setSpeaking(value) { this.speaking = value; /** * Emitted when the dispatcher starts/stops speaking @@ -127,16 +123,16 @@ class StreamDispatcher extends EventEmitter { this.emit('speaking', value); } - _sendBuffer(buffer, sequence, timestamp) { + sendBuffer(buffer, sequence, timestamp) { let repeats = this.passes; - const packet = this._createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer)); + const packet = this.createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer)); while (repeats--) { this.player.voiceConnection.sockets.udp.send(packet) .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); } } - _createPacket(sequence, timestamp, buffer) { + createPacket(sequence, timestamp, buffer) { const packetBuffer = new Buffer(buffer.length + 28); packetBuffer.fill(0); packetBuffer[0] = 0x80; @@ -154,41 +150,41 @@ class StreamDispatcher extends EventEmitter { return packetBuffer; } - _applyVolume(buffer) { - if (this._volume === 1) return buffer; + applyVolume(buffer) { + if (this.volume === 1) return buffer; const out = new Buffer(buffer.length); for (let i = 0; i < buffer.length; i += 2) { if (i >= buffer.length - 1) break; - const uint = Math.min(32767, Math.max(-32767, Math.floor(this._volume * buffer.readInt16LE(i)))); + const uint = Math.min(32767, Math.max(-32767, Math.floor(this.volume * buffer.readInt16LE(i)))); out.writeInt16LE(uint, i); } return out; } - _send() { + process() { try { - if (this._triggered) { - this._setSpeaking(false); + if (this.destroyed) { + this.setSpeaking(false); return; } const data = this.streamingData; if (data.missed >= 5) { - this._triggerTerminalState('end', 'Stream is not generating quickly enough.'); + this.destroy('end', 'Stream is not generating quickly enough.'); return; } if (this.paused) { // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); return; } - this._setSpeaking(true); + this.setSpeaking(true); if (!data.startTime) { /** @@ -204,7 +200,7 @@ class StreamDispatcher extends EventEmitter { if (!buffer) { data.missed++; data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); return; } @@ -216,89 +212,45 @@ class StreamDispatcher extends EventEmitter { buffer = newBuffer; } - buffer = this._applyVolume(buffer); + buffer = this.applyVolume(buffer); data.count++; data.sequence = (data.sequence + 1) < 65536 ? data.sequence + 1 : 0; data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - this._sendBuffer(buffer, data.sequence, data.timestamp); + this.sendBuffer(buffer, data.sequence, data.timestamp); const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), nextTime); + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), nextTime); } catch (e) { - this._triggerTerminalState('error', e); + this.destroy('error', e); } } - _triggerEnd(reason) { - /** - * Emitted once the stream has ended. Attach a `once` listener to this. - * @event StreamDispatcher#end - * @param {string} reason The reason for the end of the dispatcher. If it ended because it reached the end of the - * stream, this would be `stream`. If you invoke `.end()` without specifying a reason, this would be `user`. - */ - this.emit('end', reason); + destroy(type, reason) { + if (this.destroyed) return; + this.destroyed = true; + this.setSpeaking(false); + this.emit(type, reason); } - _triggerError(err) { - this.emit('end'); - /** - * Emitted once the stream has encountered an error. Attach a `once` listener to this. Also emits `end`. - * @event StreamDispatcher#error - * @param {Error} err The encountered error - */ - this.emit('error', err); - } - - _triggerTerminalState(state, err) { - if (this._triggered) return; - /** - * Emitted when the stream wants to give debug information. - * @event StreamDispatcher#debug - * @param {string} information The debug information - */ - this.emit('debug', `Triggered terminal state ${state} - stream is now dead`); - this._triggered = true; - this._setSpeaking(false); - switch (state) { - case 'end': - this._triggerEnd(err); - break; - case 'error': - this._triggerError(err); - break; - default: - this.emit('error', 'Unknown trigger state'); - break; - } - } - - _startStreaming() { + startStreaming() { if (!this.stream) { this.emit('error', 'No stream'); return; } - this.stream.on('end', err => this._triggerTerminalState('end', err || 'stream')); - this.stream.on('error', err => this._triggerTerminalState('error', err)); + this.stream.on('end', err => this.destroy('end', err || 'stream')); + this.stream.on('error', err => this.destroy('error', err)); const data = this.streamingData; data.length = 20; data.missed = 0; - this.stream.once('readable', () => this._send()); + this.stream.once('readable', () => this.process()); } - _setPaused(paused) { - if (paused) { - this.paused = true; - this._setSpeaking(false); - } else { - this.paused = false; - this._setSpeaking(true); - } - } + setPaused(paused) { this.setSpeaking(!(this.paused = paused)); } } module.exports = StreamDispatcher; diff --git a/src/client/voice/dispatcher/StreamDispatcher.old.js b/src/client/voice/dispatcher/StreamDispatcher.old.js new file mode 100644 index 000000000..a56a4a98e --- /dev/null +++ b/src/client/voice/dispatcher/StreamDispatcher.old.js @@ -0,0 +1,304 @@ +const EventEmitter = require('events').EventEmitter; +const NaCl = require('tweetnacl'); + +const nonce = new Buffer(24); +nonce.fill(0); + +/** + * The class that sends voice packet data to the voice connection. + * ```js + * // obtained using: + * voiceChannel.join().then(connection => { + * // you can play a file or a stream here: + * const dispatcher = connection.playFile('./file.mp3'); + * }); + * ``` + * @extends {EventEmitter} + */ +class StreamDispatcher extends EventEmitter { + constructor(player, stream, streamOptions) { + super(); + this.player = player; + this.stream = stream; + this._startStreaming(); + this._triggered = false; + this._volume = streamOptions.volume; + + /** + * How many passes the dispatcher should take when sending packets to reduce packet loss. Values over 5 + * aren't recommended, as it means you are using 5x more bandwidth. You _can_ edit this at runtime. + * @type {number} + */ + this.passes = streamOptions.passes || 1; + + /** + * Whether playing is paused + * @type {boolean} + */ + this.paused = false; + + this.setVolume(streamOptions.volume || 1); + } + + get streamingData() { + return this.player.streamingData; + } + + /** + * How long the stream dispatcher has been "speaking" for + * @type {number} + * @readonly + */ + get time() { + return this.streamingData.count * (this.streamingData.length || 0); + } + + /** + * The total time, taking into account pauses and skips, that the dispatcher has been streaming for + * @type {number} + * @readonly + */ + get totalStreamTime() { + return this.time + this.streamingData.pausedTime; + } + + /** + * The volume of the stream, relative to the stream's input volume + * @type {number} + * @readonly + */ + get volume() { + return this._volume; + } + + /** + * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. + * @param {number} volume The volume that you want to set + */ + setVolume(volume) { + this._volume = volume; + } + + /** + * Set the volume in decibels + * @param {number} db The decibels + */ + setVolumeDecibels(db) { + this._volume = Math.pow(10, db / 20); + } + + /** + * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. + * @param {number} value The value for the volume + */ + setVolumeLogarithmic(value) { + this._volume = Math.pow(value, 1.660964); + } + + /** + * Stops sending voice packets to the voice connection (stream may still progress however) + */ + pause() { + this._setPaused(true); + } + + /** + * Resumes sending voice packets to the voice connection (may be further on in the stream than when paused) + */ + resume() { + this._setPaused(false); + } + + /** + * Stops the current stream permanently and emits an `end` event. + * @param {string} [reason='user'] An optional reason for stopping the dispatcher. + */ + end(reason = 'user') { + this._triggerTerminalState('end', reason); + } + + _setSpeaking(value) { + this.speaking = value; + /** + * Emitted when the dispatcher starts/stops speaking + * @event StreamDispatcher#speaking + * @param {boolean} value Whether or not the dispatcher is speaking + */ + this.emit('speaking', value); + } + + _sendBuffer(buffer, sequence, timestamp) { + let repeats = this.passes; + const packet = this._createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer)); + while (repeats--) { + this.player.voiceConnection.sockets.udp.send(packet) + .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); + } + } + + _createPacket(sequence, timestamp, buffer) { + const packetBuffer = new Buffer(buffer.length + 28); + packetBuffer.fill(0); + packetBuffer[0] = 0x80; + packetBuffer[1] = 0x78; + + packetBuffer.writeUIntBE(sequence, 2, 2); + packetBuffer.writeUIntBE(timestamp, 4, 4); + packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc, 8, 4); + + packetBuffer.copy(nonce, 0, 0, 12); + buffer = NaCl.secretbox(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key); + + for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i]; + + return packetBuffer; + } + + _applyVolume(buffer) { + if (this._volume === 1) return buffer; + + const out = new Buffer(buffer.length); + for (let i = 0; i < buffer.length; i += 2) { + if (i >= buffer.length - 1) break; + const uint = Math.min(32767, Math.max(-32767, Math.floor(this._volume * buffer.readInt16LE(i)))); + out.writeInt16LE(uint, i); + } + + return out; + } + + _send() { + try { + if (this._triggered) { + this._setSpeaking(false); + return; + } + + const data = this.streamingData; + + if (data.missed >= 5) { + this._triggerTerminalState('end', 'Stream is not generating quickly enough.'); + return; + } + + if (this.paused) { + // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; + data.pausedTime += data.length * 10; + this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); + return; + } + + this._setSpeaking(true); + + if (!data.startTime) { + /** + * Emitted once the dispatcher starts streaming + * @event StreamDispatcher#start + */ + this.emit('start'); + data.startTime = Date.now(); + } + + const bufferLength = 1920 * data.channels; + let buffer = this.stream.read(bufferLength); + if (!buffer) { + data.missed++; + data.pausedTime += data.length * 10; + this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); + return; + } + + data.missed = 0; + + if (buffer.length !== bufferLength) { + const newBuffer = new Buffer(bufferLength).fill(0); + buffer.copy(newBuffer); + buffer = newBuffer; + } + + buffer = this._applyVolume(buffer); + + data.count++; + data.sequence = (data.sequence + 1) < 65536 ? data.sequence + 1 : 0; + data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; + + this._sendBuffer(buffer, data.sequence, data.timestamp); + + const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); + this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), nextTime); + } catch (e) { + this._triggerTerminalState('error', e); + } + } + + _triggerEnd(reason) { + /** + * Emitted once the stream has ended. Attach a `once` listener to this. + * @event StreamDispatcher#end + * @param {string} reason The reason for the end of the dispatcher. If it ended because it reached the end of the + * stream, this would be `stream`. If you invoke `.end()` without specifying a reason, this would be `user`. + */ + this.emit('end', reason); + } + + _triggerError(err) { + this.emit('end'); + /** + * Emitted once the stream has encountered an error. Attach a `once` listener to this. Also emits `end`. + * @event StreamDispatcher#error + * @param {Error} err The encountered error + */ + this.emit('error', err); + } + + _triggerTerminalState(state, err) { + if (this._triggered) return; + /** + * Emitted when the stream wants to give debug information. + * @event StreamDispatcher#debug + * @param {string} information The debug information + */ + this.emit('debug', `Triggered terminal state ${state} - stream is now dead`); + this._triggered = true; + this._setSpeaking(false); + switch (state) { + case 'end': + this._triggerEnd(err); + break; + case 'error': + this._triggerError(err); + break; + default: + this.emit('error', 'Unknown trigger state'); + break; + } + } + + _startStreaming() { + if (!this.stream) { + this.emit('error', 'No stream'); + return; + } + + this.stream.on('end', err => this._triggerTerminalState('end', err || 'stream')); + this.stream.on('error', err => this._triggerTerminalState('error', err)); + + const data = this.streamingData; + data.length = 20; + data.missed = 0; + + this.stream.once('readable', () => this._send()); + } + + _setPaused(paused) { + if (paused) { + this.paused = true; + this._setSpeaking(false); + } else { + this.paused = false; + this._setSpeaking(true); + } + } +} + +module.exports = StreamDispatcher; diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 64c54630f..d9f1144a2 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -18,7 +18,7 @@ class AudioPlayer extends EventEmitter { this.voiceConnection = voiceConnection; this.prism = new Prism(); this.opusEncoder = OpusEncoders.fetch(); - this.transcoders = new Collection(); + this.streams = new Collection(); this.streamingData = { channels: 2, count: 0, @@ -29,14 +29,17 @@ class AudioPlayer extends EventEmitter { } get currentTranscoder() { - return this.transcoders.last(); + return this.streams.last().transcoder; } - destroyAllTranscoders(exceptLatest) { - for (const stream of this.transcoders.keys()) { - const transcoder = this.transcoders.get(stream); + destroyAllStreams(exceptLatest) { + for (const stream of this.streams.keys()) { + const data = this.streams.get(stream); + const transcoder = data.transcoder; + const dispatcher = data.dispatcher; if (exceptLatest && transcoder === this.currentTranscoder) continue; - transcoder.kill(); + if (transcoder) transcoder.kill(); + if (dispatcher) dispatcher.destroy('end'); } } @@ -47,15 +50,17 @@ class AudioPlayer extends EventEmitter { media: stream, ffmpegArguments, }); - this.transcoders.set(stream, transcoder); + this.streams.set(stream, { transcoder }); this.playPCMStream(transcoder.output, options); } playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - this.destroyAllTranscoders(true); + this.destroyAllStreams(true); const dispatcher = new StreamDispatcher(this, stream, options); dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + if (!this.streams.has(stream)) this.streams.set(stream, { dispatcher }); + this.streams.get(stream).dispatcher = dispatcher; return dispatcher; } } From 8e75b47a7b2c94df0169f649726cd400f4aac2e2 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 17:37:56 +0000 Subject: [PATCH 03/53] add back seek option --- src/client/voice/player/AudioPlayer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index d9f1144a2..fce6a69b7 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -48,7 +48,7 @@ class AudioPlayer extends EventEmitter { const transcoder = this.prism.transcode({ type: 'ffmpeg', media: stream, - ffmpegArguments, + ffmpegArguments: ffmpegArguments.concat(['-ss', String(seek)]), }); this.streams.set(stream, { transcoder }); this.playPCMStream(transcoder.output, options); From 0a47d0e1d63835387fb54a8525d4303d7427c5f3 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 18:16:26 +0000 Subject: [PATCH 04/53] Remove old stuff --- src/client/voice/VoiceConnection.js | 1 - .../voice/dispatcher/StreamDispatcher.old.js | 304 ------------------ src/client/voice/player/AudioPlayer.js | 25 +- src/client/voice/player/AudioPlayer.old.js | 100 ------ src/client/voice/player/BasePlayer.old.js | 121 ------- src/client/voice/player/DefaultPlayer.old.js | 19 -- 6 files changed, 18 insertions(+), 552 deletions(-) delete mode 100644 src/client/voice/dispatcher/StreamDispatcher.old.js delete mode 100644 src/client/voice/player/AudioPlayer.old.js delete mode 100644 src/client/voice/player/BasePlayer.old.js delete mode 100644 src/client/voice/player/DefaultPlayer.old.js diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 08cc1f924..5c8d72771 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -79,7 +79,6 @@ class VoiceConnection extends EventEmitter { * @param {string|Error} warning the warning */ this.emit('warn', e); - this.player.cleanup(); }); /** diff --git a/src/client/voice/dispatcher/StreamDispatcher.old.js b/src/client/voice/dispatcher/StreamDispatcher.old.js deleted file mode 100644 index a56a4a98e..000000000 --- a/src/client/voice/dispatcher/StreamDispatcher.old.js +++ /dev/null @@ -1,304 +0,0 @@ -const EventEmitter = require('events').EventEmitter; -const NaCl = require('tweetnacl'); - -const nonce = new Buffer(24); -nonce.fill(0); - -/** - * The class that sends voice packet data to the voice connection. - * ```js - * // obtained using: - * voiceChannel.join().then(connection => { - * // you can play a file or a stream here: - * const dispatcher = connection.playFile('./file.mp3'); - * }); - * ``` - * @extends {EventEmitter} - */ -class StreamDispatcher extends EventEmitter { - constructor(player, stream, streamOptions) { - super(); - this.player = player; - this.stream = stream; - this._startStreaming(); - this._triggered = false; - this._volume = streamOptions.volume; - - /** - * How many passes the dispatcher should take when sending packets to reduce packet loss. Values over 5 - * aren't recommended, as it means you are using 5x more bandwidth. You _can_ edit this at runtime. - * @type {number} - */ - this.passes = streamOptions.passes || 1; - - /** - * Whether playing is paused - * @type {boolean} - */ - this.paused = false; - - this.setVolume(streamOptions.volume || 1); - } - - get streamingData() { - return this.player.streamingData; - } - - /** - * How long the stream dispatcher has been "speaking" for - * @type {number} - * @readonly - */ - get time() { - return this.streamingData.count * (this.streamingData.length || 0); - } - - /** - * The total time, taking into account pauses and skips, that the dispatcher has been streaming for - * @type {number} - * @readonly - */ - get totalStreamTime() { - return this.time + this.streamingData.pausedTime; - } - - /** - * The volume of the stream, relative to the stream's input volume - * @type {number} - * @readonly - */ - get volume() { - return this._volume; - } - - /** - * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. - * @param {number} volume The volume that you want to set - */ - setVolume(volume) { - this._volume = volume; - } - - /** - * Set the volume in decibels - * @param {number} db The decibels - */ - setVolumeDecibels(db) { - this._volume = Math.pow(10, db / 20); - } - - /** - * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. - * @param {number} value The value for the volume - */ - setVolumeLogarithmic(value) { - this._volume = Math.pow(value, 1.660964); - } - - /** - * Stops sending voice packets to the voice connection (stream may still progress however) - */ - pause() { - this._setPaused(true); - } - - /** - * Resumes sending voice packets to the voice connection (may be further on in the stream than when paused) - */ - resume() { - this._setPaused(false); - } - - /** - * Stops the current stream permanently and emits an `end` event. - * @param {string} [reason='user'] An optional reason for stopping the dispatcher. - */ - end(reason = 'user') { - this._triggerTerminalState('end', reason); - } - - _setSpeaking(value) { - this.speaking = value; - /** - * Emitted when the dispatcher starts/stops speaking - * @event StreamDispatcher#speaking - * @param {boolean} value Whether or not the dispatcher is speaking - */ - this.emit('speaking', value); - } - - _sendBuffer(buffer, sequence, timestamp) { - let repeats = this.passes; - const packet = this._createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer)); - while (repeats--) { - this.player.voiceConnection.sockets.udp.send(packet) - .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); - } - } - - _createPacket(sequence, timestamp, buffer) { - const packetBuffer = new Buffer(buffer.length + 28); - packetBuffer.fill(0); - packetBuffer[0] = 0x80; - packetBuffer[1] = 0x78; - - packetBuffer.writeUIntBE(sequence, 2, 2); - packetBuffer.writeUIntBE(timestamp, 4, 4); - packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc, 8, 4); - - packetBuffer.copy(nonce, 0, 0, 12); - buffer = NaCl.secretbox(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key); - - for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i]; - - return packetBuffer; - } - - _applyVolume(buffer) { - if (this._volume === 1) return buffer; - - const out = new Buffer(buffer.length); - for (let i = 0; i < buffer.length; i += 2) { - if (i >= buffer.length - 1) break; - const uint = Math.min(32767, Math.max(-32767, Math.floor(this._volume * buffer.readInt16LE(i)))); - out.writeInt16LE(uint, i); - } - - return out; - } - - _send() { - try { - if (this._triggered) { - this._setSpeaking(false); - return; - } - - const data = this.streamingData; - - if (data.missed >= 5) { - this._triggerTerminalState('end', 'Stream is not generating quickly enough.'); - return; - } - - if (this.paused) { - // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); - return; - } - - this._setSpeaking(true); - - if (!data.startTime) { - /** - * Emitted once the dispatcher starts streaming - * @event StreamDispatcher#start - */ - this.emit('start'); - data.startTime = Date.now(); - } - - const bufferLength = 1920 * data.channels; - let buffer = this.stream.read(bufferLength); - if (!buffer) { - data.missed++; - data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10); - return; - } - - data.missed = 0; - - if (buffer.length !== bufferLength) { - const newBuffer = new Buffer(bufferLength).fill(0); - buffer.copy(newBuffer); - buffer = newBuffer; - } - - buffer = this._applyVolume(buffer); - - data.count++; - data.sequence = (data.sequence + 1) < 65536 ? data.sequence + 1 : 0; - data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - - this._sendBuffer(buffer, data.sequence, data.timestamp); - - const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); - this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), nextTime); - } catch (e) { - this._triggerTerminalState('error', e); - } - } - - _triggerEnd(reason) { - /** - * Emitted once the stream has ended. Attach a `once` listener to this. - * @event StreamDispatcher#end - * @param {string} reason The reason for the end of the dispatcher. If it ended because it reached the end of the - * stream, this would be `stream`. If you invoke `.end()` without specifying a reason, this would be `user`. - */ - this.emit('end', reason); - } - - _triggerError(err) { - this.emit('end'); - /** - * Emitted once the stream has encountered an error. Attach a `once` listener to this. Also emits `end`. - * @event StreamDispatcher#error - * @param {Error} err The encountered error - */ - this.emit('error', err); - } - - _triggerTerminalState(state, err) { - if (this._triggered) return; - /** - * Emitted when the stream wants to give debug information. - * @event StreamDispatcher#debug - * @param {string} information The debug information - */ - this.emit('debug', `Triggered terminal state ${state} - stream is now dead`); - this._triggered = true; - this._setSpeaking(false); - switch (state) { - case 'end': - this._triggerEnd(err); - break; - case 'error': - this._triggerError(err); - break; - default: - this.emit('error', 'Unknown trigger state'); - break; - } - } - - _startStreaming() { - if (!this.stream) { - this.emit('error', 'No stream'); - return; - } - - this.stream.on('end', err => this._triggerTerminalState('end', err || 'stream')); - this.stream.on('error', err => this._triggerTerminalState('error', err)); - - const data = this.streamingData; - data.length = 20; - data.missed = 0; - - this.stream.once('readable', () => this._send()); - } - - _setPaused(paused) { - if (paused) { - this.paused = true; - this._setSpeaking(false); - } else { - this.paused = false; - this._setSpeaking(true); - } - } -} - -module.exports = StreamDispatcher; diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index fce6a69b7..fec034184 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -32,14 +32,20 @@ class AudioPlayer extends EventEmitter { return this.streams.last().transcoder; } - destroyAllStreams(exceptLatest) { + destroyStream(stream) { + const data = this.streams.get(stream); + if (!data) return; + const transcoder = data.transcoder; + const dispatcher = data.dispatcher; + if (transcoder) transcoder.kill(); + if (dispatcher) dispatcher.destroy('end'); + } + + destroyAllStreams(except) { for (const stream of this.streams.keys()) { - const data = this.streams.get(stream); - const transcoder = data.transcoder; - const dispatcher = data.dispatcher; - if (exceptLatest && transcoder === this.currentTranscoder) continue; - if (transcoder) transcoder.kill(); - if (dispatcher) dispatcher.destroy('end'); + if (except === stream) continue; + if (except === true && this.streams.get(stream) === this.streams.last()) continue; + this.destroyStream(stream); } } @@ -51,6 +57,11 @@ class AudioPlayer extends EventEmitter { ffmpegArguments: ffmpegArguments.concat(['-ss', String(seek)]), }); this.streams.set(stream, { transcoder }); + transcoder.on('error', e => { + this.destroyStream(stream); + if (this.listenerCount('error') > 0) this.emit('error', e); + else this.emit('warn', e); + }); this.playPCMStream(transcoder.output, options); } diff --git a/src/client/voice/player/AudioPlayer.old.js b/src/client/voice/player/AudioPlayer.old.js deleted file mode 100644 index 7e9ce616b..000000000 --- a/src/client/voice/player/AudioPlayer.old.js +++ /dev/null @@ -1,100 +0,0 @@ -const OpusEncoders = require('../opus/OpusEngineList'); -const EventEmitter = require('events').EventEmitter; -const StreamDispatcher = require('../dispatcher/StreamDispatcher'); - -const ffmpegArguments = [ - '-analyzeduration', '0', - '-loglevel', '0', - '-f', 's16le', - '-ar', '48000', - '-ac', '2', -]; - -/** - * Represents the Audio Player of a Voice Connection - * @extends {EventEmitter} - * @private - */ -class AudioPlayer extends EventEmitter { - constructor(voiceConnection) { - super(); - /** - * The voice connection the player belongs to - * @type {VoiceConnection} - */ - this.voiceConnection = voiceConnection; - this.opusEncoder = OpusEncoders.fetch(); - this.currentConverter = null; - /** - * The current stream dispatcher, if a stream is being played - * @type {StreamDispatcher} - */ - this.dispatcher = null; - // this.prism.on('error', e => this.emit('error', e)); - this.streamingData = { - channels: 2, - count: 0, - sequence: 0, - timestamp: 0, - pausedTime: 0, - }; - this.voiceConnection.on('closing', () => this.cleanup(null, 'voice connection closing')); - } - - get prism() { - return this.voiceConnection.prism; - } - - playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - const transcoder = this.prism.transcode({ - type: 'ffmpeg', - media: stream, - ffmpegArguments, - }); - this.playPCMStream(transcoder.output, options); - } - - /*playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - stream.on('end', () => { - this.emit('debug', 'Input stream to converter has ended'); - }); - stream.on('error', e => this.emit('error', e)); - const conversionProcess = this.audioToPCM.createConvertStream(options.seek); - conversionProcess.on('error', e => this.emit('error', e)); - conversionProcess.setInput(stream); - return this.playPCMStream(conversionProcess.process.stdout, conversionProcess, options); -}*/ - - cleanup(checkStream, reason) { - // cleanup is a lot less aggressive than v9 because it doesn't try to kill every single stream it is aware of - this.emit('debug', `Clean up triggered due to ${reason}`); - const filter = checkStream && this.dispatcher && this.dispatcher.stream === checkStream; - if (this.currentConverter && (checkStream ? filter : true)) { - this.currentConverter.destroy(); - this.currentConverter = null; - } - } - - playPCMStream(stream, converter, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - stream.on('end', () => this.emit('debug', 'PCM input stream ended')); - this.cleanup(null, 'outstanding play stream'); - this.currentConverter = converter; - if (this.dispatcher) { - this.streamingData = this.dispatcher.streamingData; - } - stream.on('error', e => this.emit('error', e)); - const dispatcher = new StreamDispatcher(this, stream, this.streamingData, options); - dispatcher.on('error', e => this.emit('error', e)); - dispatcher.on('end', () => this.cleanup(dispatcher.stream, 'dispatcher ended')); - dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); - this.dispatcher = dispatcher; - dispatcher.on('debug', m => this.emit('debug', `Stream dispatch - ${m}`)); - return dispatcher; - } - -} - -module.exports = AudioPlayer; diff --git a/src/client/voice/player/BasePlayer.old.js b/src/client/voice/player/BasePlayer.old.js deleted file mode 100644 index d5285cd34..000000000 --- a/src/client/voice/player/BasePlayer.old.js +++ /dev/null @@ -1,121 +0,0 @@ -const OpusEngines = require('../opus/OpusEngineList'); -const ConverterEngines = require('../pcm/ConverterEngineList'); -const Constants = require('../../../util/Constants'); -const StreamDispatcher = require('../dispatcher/StreamDispatcher'); -const EventEmitter = require('events').EventEmitter; - -class VoiceConnectionPlayer extends EventEmitter { - constructor(connection) { - super(); - this.connection = connection; - this.opusEncoder = OpusEngines.fetch(); - const Engine = ConverterEngines.fetch(); - this.converterEngine = new Engine(this); - this.converterEngine.on('error', err => { - this._shutdown(); - this.emit('error', err); - }); - this.speaking = false; - this.processMap = new Map(); - this.dispatcher = null; - this._streamingData = { - sequence: 0, - timestamp: 0, - }; - } - - convertStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - const encoder = this.converterEngine.createConvertStream(options.seek); - const pipe = stream.pipe(encoder.stdin, { end: false }); - pipe.on('unpipe', () => { - this.killStream(encoder.stdout); - pipe.destroy(); - }); - this.processMap.set(encoder.stdout, { - pcmConverter: encoder, - inputStream: stream, - }); - return encoder.stdout; - } - - _shutdown() { - this.speaking = false; - if (this.dispatcher) this.dispatcher._triggerTerminalState('end', 'ended by parent player shutdown'); - for (const stream of this.processMap.keys()) this.killStream(stream); - } - - killStream(stream) { - const streams = this.processMap.get(stream); - this._streamingData = this.dispatcher.streamingData; - this.emit(Constants.Events.DEBUG, 'Cleaning up player after audio stream ended or encountered an error'); - - const dummyHandler = () => null; - - if (streams) { - this.processMap.delete(stream); - if (streams.inputStream && streams.pcmConverter) { - try { - streams.inputStream.once('error', dummyHandler); - streams.pcmConverter.once('error', dummyHandler); - streams.pcmConverter.stdin.once('error', dummyHandler); - streams.pcmConverter.stdout.once('error', dummyHandler); - if (streams.inputStream.unpipe) { - streams.inputStream.unpipe(streams.pcmConverter.stdin); - this.emit(Constants.Events.DEBUG, '- Unpiped input stream'); - } else if (streams.inputStream.destroy) { - streams.inputStream.destroy(); - this.emit(Constants.Events.DEBUG, '- Couldn\'t unpipe input stream, so destroyed input stream'); - } - if (streams.pcmConverter.stdin) { - streams.pcmConverter.stdin.end(); - this.emit(Constants.Events.DEBUG, '- Ended input stream to PCM converter'); - } - if (streams.pcmConverter && streams.pcmConverter.kill) { - streams.pcmConverter.kill('SIGINT'); - this.emit(Constants.Events.DEBUG, '- Killed the PCM converter'); - } - } catch (err) { - // if an error happened make sure the pcm converter is killed anyway - try { - if (streams.pcmConverter && streams.pcmConverter.kill) { - streams.pcmConverter.kill('SIGINT'); - this.emit(Constants.Events.DEBUG, '- Killed the PCM converter after previous error (abnormal)'); - } - } catch (e) { - return e; - } - return err; - } - } - } - return null; - } - - setSpeaking(value) { - if (this.speaking === value) return; - this.speaking = value; - this.connection.websocket.send({ - op: Constants.VoiceOPCodes.SPEAKING, - d: { - speaking: true, - delay: 0, - }, - }).catch(e => { - this.emit('debug', e); - }); - } - - playPCMStream(pcmStream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - const dispatcher = new StreamDispatcher(this, pcmStream, this._streamingData, options); - dispatcher.on('speaking', value => this.setSpeaking(value)); - dispatcher.on('end', () => this.killStream(pcmStream)); - dispatcher.on('error', () => this.killStream(pcmStream)); - dispatcher.setVolume(options.volume); - this.dispatcher = dispatcher; - return dispatcher; - } -} - -module.exports = VoiceConnectionPlayer; diff --git a/src/client/voice/player/DefaultPlayer.old.js b/src/client/voice/player/DefaultPlayer.old.js deleted file mode 100644 index b465e8cd6..000000000 --- a/src/client/voice/player/DefaultPlayer.old.js +++ /dev/null @@ -1,19 +0,0 @@ -const BasePlayer = require('./BasePlayer'); -const fs = require('fs'); - -class DefaultPlayer extends BasePlayer { - playFile(file, { seek = 0, volume = 1 } = {}) { - const options = { seek: seek, volume: volume }; - return this.playStream(fs.createReadStream(file), options); - } - - playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - this._shutdown(); - const options = { seek, volume, passes }; - const pcmStream = this.convertStream(stream, options); - const dispatcher = this.playPCMStream(pcmStream, options); - return dispatcher; - } -} - -module.exports = DefaultPlayer; From 4294d267e7dd29d9e5a96aee3a72368619baf65c Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 19:23:11 +0000 Subject: [PATCH 05/53] Add playFile back to voice connection --- src/client/voice/VoiceConnection.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 5c8d72771..afcc5b737 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -231,8 +231,9 @@ class VoiceConnection extends EventEmitter { * }) * .catch(console.error); */ - playFile(file, options) { - return this.playStream(fs.createReadStream(file), options); + playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + return this.player.playUnknownStream(file, options); } /** From 00254f35b000fe2fd16b983f0c45ded225e00009 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 21:40:11 +0000 Subject: [PATCH 06/53] Make voice return streamdispatcher --- src/client/voice/player/AudioPlayer.js | 2 +- test/random.js | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index fec034184..78f9d57b1 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -62,7 +62,7 @@ class AudioPlayer extends EventEmitter { if (this.listenerCount('error') > 0) this.emit('error', e); else this.emit('warn', e); }); - this.playPCMStream(transcoder.output, options); + return this.playPCMStream(transcoder.output, options); } playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { diff --git a/test/random.js b/test/random.js index cc65fc759..e4a0569a8 100644 --- a/test/random.js +++ b/test/random.js @@ -10,8 +10,6 @@ const { email, password, token, usertoken, song } = require('./auth.json'); client.login(token).then(atoken => console.log('logged in with token ' + atoken)).catch(console.error); -client.ws.on('send', console.log); - client.on('ready', () => { console.log('ready'); }); From e7824d6515b5366648ea25d61d04930b7be68bac Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 23:21:34 +0000 Subject: [PATCH 07/53] Fix weird audio playback bug between subsequent streams on an AudioPlayer --- src/client/voice/dispatcher/StreamDispatcher.js | 7 +++++-- test/random.js | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 6e3cfd207..49c883b7a 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -217,7 +217,6 @@ class StreamDispatcher extends EventEmitter { data.count++; data.sequence = (data.sequence + 1) < 65536 ? data.sequence + 1 : 0; data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - this.sendBuffer(buffer, data.sequence, data.timestamp); const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); @@ -247,7 +246,11 @@ class StreamDispatcher extends EventEmitter { data.length = 20; data.missed = 0; - this.stream.once('readable', () => this.process()); + this.stream.once('readable', () => { + data.startTime = null; + data.count = 0; + this.process(); + }); } setPaused(paused) { this.setSpeaking(!(this.paused = paused)); } diff --git a/test/random.js b/test/random.js index e4a0569a8..0a4a2e071 100644 --- a/test/random.js +++ b/test/random.js @@ -169,7 +169,9 @@ client.on('message', msg => { if (msg.content.startsWith('/play')) { console.log('I am now going to play', msg.content); const chan = msg.content.split(' ').slice(1).join(' '); - con.playStream(ytdl(chan, {filter : 'audioonly'}), { passes : 4 }); + const s = ytdl(chan, {filter:'audioonly'}, { passes : 3 }); + s.on('error', e => console.log(`e w stream 1 ${e}`)); + con.playStream(s); } if (msg.content.startsWith('/join')) { const chan = msg.content.split(' ').slice(1).join(' '); @@ -177,7 +179,9 @@ client.on('message', msg => { .then(conn => { con = conn; msg.reply('done'); - disp = conn.playStream(ytdl(song, {filter:'audioonly'}), { passes : 3 }); + const s = ytdl(song, {filter:'audioonly'}, { passes : 3 }); + s.on('error', e => console.log(`e w stream 2 ${e}`)); + disp = conn.playStream(s); conn.player.on('debug', console.log); conn.player.on('error', err => console.log(123, err)); }) From e64d9c6057f51bbffa7ab267e543b0e07715a4ec Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 28 Dec 2016 23:58:01 +0000 Subject: [PATCH 08/53] fix odd bug --- src/client/voice/player/AudioPlayer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 78f9d57b1..eb6049625 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -39,6 +39,7 @@ class AudioPlayer extends EventEmitter { const dispatcher = data.dispatcher; if (transcoder) transcoder.kill(); if (dispatcher) dispatcher.destroy('end'); + this.streams.delete(stream); } destroyAllStreams(except) { From 18e04d69f16d75eed558828672e3ac76236e9afc Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 00:16:13 +0000 Subject: [PATCH 09/53] Delete stream setups after they have ended or errored --- src/client/voice/player/AudioPlayer.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index eb6049625..da8e2170e 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -57,7 +57,7 @@ class AudioPlayer extends EventEmitter { media: stream, ffmpegArguments: ffmpegArguments.concat(['-ss', String(seek)]), }); - this.streams.set(stream, { transcoder }); + this.streams.set(transcoder.output, { transcoder, input: stream }); transcoder.on('error', e => { this.destroyStream(stream); if (this.listenerCount('error') > 0) this.emit('error', e); @@ -68,11 +68,13 @@ class AudioPlayer extends EventEmitter { playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - this.destroyAllStreams(true); + this.destroyAllStreams(stream); const dispatcher = new StreamDispatcher(this, stream, options); dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); - if (!this.streams.has(stream)) this.streams.set(stream, { dispatcher }); + if (!this.streams.has(stream)) this.streams.set(stream, { dispatcher, input: stream }); this.streams.get(stream).dispatcher = dispatcher; + dispatcher.on('end', () => this.destroyStream(stream)); + dispatcher.on('error', () => this.destroyStream(stream)); return dispatcher; } } From 627a8870f52ba7cd7fb7593f3c06734ead93104b Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 14:14:27 +0000 Subject: [PATCH 10/53] change error emission logic --- src/client/voice/player/AudioPlayer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index da8e2170e..65067d79a 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -61,7 +61,7 @@ class AudioPlayer extends EventEmitter { transcoder.on('error', e => { this.destroyStream(stream); if (this.listenerCount('error') > 0) this.emit('error', e); - else this.emit('warn', e); + this.emit('warn', `prism transcoder error - ${e}`); }); return this.playPCMStream(transcoder.output, options); } From 4541b3e2646b25fb158a4594dee6b4fb23f63c8b Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 16:10:51 +0000 Subject: [PATCH 11/53] Simplify check in StreamDispatcher and add test voice bot --- .../voice/dispatcher/StreamDispatcher.js | 3 +- test/voice.js | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 test/voice.js diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 49c883b7a..64c393d98 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -215,7 +215,7 @@ class StreamDispatcher extends EventEmitter { buffer = this.applyVolume(buffer); data.count++; - data.sequence = (data.sequence + 1) < 65536 ? data.sequence + 1 : 0; + data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0; data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; this.sendBuffer(buffer, data.sequence, data.timestamp); @@ -231,6 +231,7 @@ class StreamDispatcher extends EventEmitter { this.destroyed = true; this.setSpeaking(false); this.emit(type, reason); + if (type !== 'end') this.emit('end', `destroyed due to ${type} - ${reason}`); } startStreaming() { diff --git a/test/voice.js b/test/voice.js new file mode 100644 index 000000000..c7d3b04d6 --- /dev/null +++ b/test/voice.js @@ -0,0 +1,60 @@ +/* eslint no-console: 0 */ +'use strict'; + +const Discord = require('../'); +const ytdl = require('ytdl-core'); + +const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' }); + +const auth = require('./auth.json'); + +client.login(auth.token).then(() => console.log('logged')).catch(console.error); + +const connections = new Map(); + +client.on('message', m => { + if (!m.guild) return; + if (m.content.startsWith('/join')) { + const channel = m.guild.channels.get(m.content.split(' ')[1]) || m.member.voiceChannel; + if (channel && channel.type === 'voice') { + channel.join().then(conn => { + conn.player.on('error', (...e) => console.log('player', ...e)); + if (!connections.has(m.guild.id)) connections.set(m.guild.id, { conn, queue: [] }); + m.reply('ok!'); + }); + } else { + m.reply('Specify a voice channel!'); + } + } else if (m.content.startsWith('/play')) { + if (connections.has(m.guild.id)) { + const connData = connections.get(m.guild.id); + const queue = connData.queue; + const url = m.content.split(' ').slice(1).join(' ') + .replace(//g, ''); + queue.push({ url, m }); + if (queue.length > 1) { + m.reply(`OK, that's going to play after ${queue.length - 1} songs`); + return; + } + doQueue(connData); + } + } +}); + +function doQueue(connData) { + const conn = connData.conn; + const queue = connData.queue; + const item = queue[0]; + if (!item) return; + const stream = ytdl(item.url, { filter: 'audioonly' }, { passes: 3 }); + const dispatcher = conn.playStream(stream); + stream.on('info', info => { + item.m.reply(`OK, playing **${info.title}**`); + }); + dispatcher.on('end', () => { + queue.shift(); + doQueue(connData); + }); + dispatcher.on('error', (...e) => console.log('dispatcher', ...e)); +} From 72a99f9582f9596c318d6c3b3939cde8ec2fd06a Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 21:22:13 +0000 Subject: [PATCH 12/53] start work with broadcast streams --- src/client/Client.js | 13 +++ src/client/voice/VoiceBroadcast.js | 85 +++++++++++++++++++ src/client/voice/VoiceConnection.js | 6 +- .../voice/dispatcher/StreamDispatcher.js | 33 +++++-- src/client/voice/player/AudioPlayer.js | 17 ++++ test/voice.js | 9 ++ 6 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 src/client/voice/VoiceBroadcast.js diff --git a/src/client/Client.js b/src/client/Client.js index 11cd5f403..2e7d18157 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -11,6 +11,7 @@ const ActionsManager = require('./actions/ActionsManager'); const Collection = require('../util/Collection'); const Presence = require('../structures/Presence').Presence; const ShardClientUtil = require('../sharding/ShardClientUtil'); +const VoiceBroadcast = require('./voice/VoiceBroadcast'); /** * The starting point for making a Discord Bot. @@ -136,6 +137,12 @@ class Client extends EventEmitter { */ this.readyAt = null; + /** + * An array of voice broadcasts + * @type {VoiceBroadcast[]} + */ + this.broadcasts = []; + /** * The previous heartbeat pings of the websocket (most recent first, limited to three elements) * @type {number[]} @@ -219,6 +226,12 @@ class Client extends EventEmitter { return typeof window !== 'undefined'; } + createVoiceBroadcast() { + const broadcast = new VoiceBroadcast(this); + this.broadcasts.push(broadcast); + return broadcast; + } + /** * Logs the client in. If successful, resolves with the account's token. If you're making a bot, it's * much better to use a bot account rather than a user account. diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js new file mode 100644 index 000000000..3971264ed --- /dev/null +++ b/src/client/voice/VoiceBroadcast.js @@ -0,0 +1,85 @@ +const EventEmitter = require('events').EventEmitter; +const Prism = require('prism-media'); + +const ffmpegArguments = [ + '-analyzeduration', '0', + '-loglevel', '0', + '-f', 's16le', + '-ar', '48000', + '-ac', '2', +]; + +class VoiceBroadcast extends EventEmitter { + constructor(client) { + super(); + this.client = client; + this.dispatchers = []; + this.prism = new Prism(); + this.currentTranscoder = null; + } + + get _playableStream() { + if (!this.currentTranscoder) return null; + return this.currentTranscoder.transcoder.output || this.currentTranscoder.options.stream; + } + + registerDispatcher(dispatcher) { + if (!this.dispatchers.includes(dispatcher)) this.dispatchers.push(dispatcher); + } + + killCurrentTranscoder() { + if (this.currentTranscoder) { + if (this.currentTranscoder.transcoder) this.currentTranscoder.transcoder.kill(); + this.currentTranscoder = null; + } + } + + playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + options.stream = stream; + return this._playTranscodable(stream, options); + } + + playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + return this._playTranscodable(file, options); + } + + _playTranscodable(media, options) { + this.killCurrentTranscoder(); + const transcoder = this.prism.transcode({ + type: 'ffmpeg', + media, + ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek)]), + }); + transcoder.once('error', e => this.emit('error', e)); + transcoder.once('end', () => this.killCurrentTranscoder()); + this.currentTranscoder = { + transcoder, + options, + }; + transcoder.output.once('readable', () => this._startPlaying()); + return this; + } + + playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + this.killCurrentTranscoder(); + const options = { seek, volume, passes, stream }; + this.currentTranscoder = { options }; + stream.once('readable', () => this._startPlaying()); + return this; + } + + _startPlaying() { + if (!this._playableStream) return; + const stream = this._playableStream; + const buffer = stream.read(1920 * 2); + + for (const dispatcher of this.dispatchers) { + setImmediate(() => dispatcher.process(buffer, true)); + } + setTimeout(this._startPlaying.bind(this), 20); + } +} + +module.exports = VoiceBroadcast; diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index afcc5b737..5aefd6b58 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -265,7 +265,11 @@ class VoiceConnection extends EventEmitter { */ playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - return this.player.playPCMStream(stream, null, options); + return this.player.playPCMStream(stream, options); + } + + playBroadcast(broadcast) { + return this.player.playBroadcast(broadcast); } /** diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 64c393d98..04e74a17a 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -1,5 +1,6 @@ const EventEmitter = require('events').EventEmitter; const NaCl = require('tweetnacl'); +const VoiceBroadcast = require('../VoiceBroadcast'); const nonce = new Buffer(24); nonce.fill(0); @@ -20,10 +21,14 @@ class StreamDispatcher extends EventEmitter { super(); this.player = player; this.stream = stream; - this.startStreaming(); + if (!(this.stream instanceof VoiceBroadcast)) this.startStreaming(); this.streamOptions = streamOptions; this.streamOptions.volume = this.streamOptions.volume || 0; + const data = this.streamingData; + data.length = 20; + data.missed = 0; + /** * Whether playing is paused * @type {boolean} @@ -163,7 +168,7 @@ class StreamDispatcher extends EventEmitter { return out; } - process() { + process(buffer, controlled) { try { if (this.destroyed) { this.setSpeaking(false); @@ -180,7 +185,14 @@ class StreamDispatcher extends EventEmitter { if (this.paused) { // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); + // if buffer is provided we are assuming a master process is controlling the dispatcher + if (!buffer) this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); + return; + } + + if (!buffer && controlled) { + data.missed++; + data.pausedTime += data.length * 10; return; } @@ -196,12 +208,14 @@ class StreamDispatcher extends EventEmitter { } const bufferLength = 1920 * data.channels; - let buffer = this.stream.read(bufferLength); - if (!buffer) { - data.missed++; - data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); - return; + if (!controlled) { + buffer = this.stream.read(bufferLength); + if (!buffer) { + data.missed++; + data.pausedTime += data.length * 10; + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); + return; + } } data.missed = 0; @@ -219,6 +233,7 @@ class StreamDispatcher extends EventEmitter { data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; this.sendBuffer(buffer, data.sequence, data.timestamp); + if (controlled) return; const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), nextTime); } catch (e) { diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 65067d79a..6a1ac90b3 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -3,6 +3,7 @@ const Prism = require('prism-media'); const StreamDispatcher = require('../dispatcher/StreamDispatcher'); const Collection = require('../../../util/Collection'); const OpusEncoders = require('../opus/OpusEngineList'); +const VoiceBroadcast = require('../VoiceBroadcast'); const ffmpegArguments = [ '-analyzeduration', '0', @@ -33,6 +34,10 @@ class AudioPlayer extends EventEmitter { } destroyStream(stream) { + if (stream instanceof VoiceBroadcast) { + this.streams.delete(stream); + return; + } const data = this.streams.get(stream); if (!data) return; const transcoder = data.transcoder; @@ -77,6 +82,18 @@ class AudioPlayer extends EventEmitter { dispatcher.on('error', () => this.destroyStream(stream)); return dispatcher; } + + playBroadcast(broadcast, { volume = 1, passes = 1 } = {}) { + const options = { volume, passes }; + this.destroyAllStreams(); + this.streams.set(broadcast, broadcast); + const dispatcher = new StreamDispatcher(this, broadcast, options); + dispatcher.on('end', () => this.destroyStream(broadcast)); + dispatcher.on('error', () => this.destroyStream(broadcast)); + dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + broadcast.registerDispatcher(dispatcher); + return dispatcher; + } } module.exports = AudioPlayer; diff --git a/test/voice.js b/test/voice.js index c7d3b04d6..e41fd53b2 100644 --- a/test/voice.js +++ b/test/voice.js @@ -12,6 +12,8 @@ client.login(auth.token).then(() => console.log('logged')).catch(console.error); const connections = new Map(); +let broadcast; + client.on('message', m => { if (!m.guild) return; if (m.content.startsWith('/join')) { @@ -39,6 +41,13 @@ client.on('message', m => { } doQueue(connData); } + } else if (m.content.startsWith('#eval') && m.author.id === '66564597481480192') { + try { + const com = eval(m.content.split(' ').slice(1).join(' ')); + m.channel.sendMessage(`\`\`\`\n${com}\`\`\``); + } catch (e) { + m.channel.sendMessage(`\`\`\`\n${e}\`\`\``); + } } }); From ad18b05d66be2b2ef596797ecc89c391a5813bed Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 21:51:23 +0000 Subject: [PATCH 13/53] Reset what is playing --- src/client/voice/VoiceBroadcast.js | 22 +++++++++++++++++-- .../voice/dispatcher/StreamDispatcher.js | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 3971264ed..a21ace8c3 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -16,6 +16,7 @@ class VoiceBroadcast extends EventEmitter { this.dispatchers = []; this.prism = new Prism(); this.currentTranscoder = null; + this.tickInterval = null; } get _playableStream() { @@ -24,7 +25,13 @@ class VoiceBroadcast extends EventEmitter { } registerDispatcher(dispatcher) { - if (!this.dispatchers.includes(dispatcher)) this.dispatchers.push(dispatcher); + if (!this.dispatchers.includes(dispatcher)) { + this.dispatchers.push(dispatcher); + dispatcher.once('end', () => { + const ind = this.dispatchers.indexOf(dispatcher); + if (ind > -1) this.dispatchers.splice(ind, 1); + }); + } } killCurrentTranscoder() { @@ -71,6 +78,11 @@ class VoiceBroadcast extends EventEmitter { } _startPlaying() { + if (this.tickInterval) clearInterval(this.tickInterval); + this.tickInterval = this.client.setInterval(this.tick.bind(this), 20); + } + + tick() { if (!this._playableStream) return; const stream = this._playableStream; const buffer = stream.read(1920 * 2); @@ -78,7 +90,13 @@ class VoiceBroadcast extends EventEmitter { for (const dispatcher of this.dispatchers) { setImmediate(() => dispatcher.process(buffer, true)); } - setTimeout(this._startPlaying.bind(this), 20); + } + + end() { + this.killCurrentTranscoder(); + for (const dispatcher of this.dispatchers) { + dispatcher.destroy('end', 'broadcast ended'); + } } } diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 04e74a17a..ee2098044 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -177,7 +177,7 @@ class StreamDispatcher extends EventEmitter { const data = this.streamingData; - if (data.missed >= 5) { + if (data.missed >= 5 && !controlled) { this.destroy('end', 'Stream is not generating quickly enough.'); return; } From 12605575fb49fffa4661e969b8bd8aa5cc110bc7 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 21:59:36 +0000 Subject: [PATCH 14/53] Add VoiceBroadcast.pause and VoiceBroadcast.resume --- src/client/voice/VoiceBroadcast.js | 14 ++++++++++++++ src/client/voice/dispatcher/StreamDispatcher.js | 1 + 2 files changed, 15 insertions(+) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index a21ace8c3..892e0330b 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -77,6 +77,20 @@ class VoiceBroadcast extends EventEmitter { return this; } + pause() { + for (const dispatcher of this.dispatchers) { + dispatcher.pause(); + } + clearInterval(this.tickInterval); + } + + resume() { + for (const dispatcher of this.dispatchers) { + dispatcher.resume(); + } + this._startPlaying(); + } + _startPlaying() { if (this.tickInterval) clearInterval(this.tickInterval); this.tickInterval = this.client.setInterval(this.tick.bind(this), 20); diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index ee2098044..7aed21ebc 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -183,6 +183,7 @@ class StreamDispatcher extends EventEmitter { } if (this.paused) { + this.setSpeaking(false); // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; data.pausedTime += data.length * 10; // if buffer is provided we are assuming a master process is controlling the dispatcher From c6f17054fcdd9eaa4a89372d2adb5c314ee27702 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Thu, 29 Dec 2016 22:59:43 +0000 Subject: [PATCH 15/53] Remove unused fs import --- src/client/voice/VoiceConnection.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 5aefd6b58..29bd56ad5 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -4,7 +4,6 @@ const Constants = require('../../util/Constants'); const AudioPlayer = require('./player/AudioPlayer'); const VoiceReceiver = require('./receiver/VoiceReceiver'); const EventEmitter = require('events').EventEmitter; -const fs = require('fs'); const Prism = require('prism-media'); /** From 77548c194fb6e76cc50940711f53bbd8b52e1d2a Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Thu, 29 Dec 2016 20:01:30 -0500 Subject: [PATCH 16/53] Add missing type --- src/client/voice/VoiceConnection.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 29bd56ad5..60dc696e5 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -26,8 +26,14 @@ class VoiceConnection extends EventEmitter { */ this.voiceManager = pendingConnection.voiceManager; + /** + * @external Prism + * @see {@link https://github.com/hydrabolt/prism-media} + */ + /** * The audio transcoder for this connection + * @type {Prism} */ this.prism = new Prism(); From 89cea574bed09dfef57f23df36c3d6b6b55e0c73 Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Thu, 29 Dec 2016 20:06:48 -0500 Subject: [PATCH 17/53] Add prism-media dependency --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 5c4895a66..272b93bfc 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@types/node": "^6.0.0", "pako": "^1.0.0", + "prism-media": "hydrabolt/prism-media#master", "superagent": "^3.3.0", "tweetnacl": "^0.14.0", "ws": "^1.1.1" @@ -57,6 +58,7 @@ "ws": false, "uws": false, "erlpack": false, + "prism-media": false, "opusscript": false, "node-opus": false, "tweet-nacl": false, From 2f630a0dbba19de65ccf73d824e76d8c6a3cd2c3 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 12:43:56 +0000 Subject: [PATCH 18/53] AudioPlayer now destroys all dispatchers on closing, Broadcasts are also destroyed properly --- src/client/voice/player/AudioPlayer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 6a1ac90b3..b43edb443 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -27,6 +27,7 @@ class AudioPlayer extends EventEmitter { timestamp: 0, pausedTime: 0, }; + this.voiceConnection.once('closing', () => this.destroyAllStreams()); } get currentTranscoder() { @@ -34,7 +35,9 @@ class AudioPlayer extends EventEmitter { } destroyStream(stream) { - if (stream instanceof VoiceBroadcast) { + if (stream instanceof VoiceBroadcast && this.streams.has(stream)) { + const data = this.streams.get(stream); + if (data.dispatcher) data.dispatcher.destroy('end'); this.streams.delete(stream); return; } From bf4010e89c4403d73c4573553dd550018e262893 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 12:46:34 +0000 Subject: [PATCH 19/53] Simplify broadcast creation --- src/client/voice/player/AudioPlayer.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index b43edb443..22b5ae953 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -35,12 +35,6 @@ class AudioPlayer extends EventEmitter { } destroyStream(stream) { - if (stream instanceof VoiceBroadcast && this.streams.has(stream)) { - const data = this.streams.get(stream); - if (data.dispatcher) data.dispatcher.destroy('end'); - this.streams.delete(stream); - return; - } const data = this.streams.get(stream); if (!data) return; const transcoder = data.transcoder; @@ -89,11 +83,11 @@ class AudioPlayer extends EventEmitter { playBroadcast(broadcast, { volume = 1, passes = 1 } = {}) { const options = { volume, passes }; this.destroyAllStreams(); - this.streams.set(broadcast, broadcast); const dispatcher = new StreamDispatcher(this, broadcast, options); dispatcher.on('end', () => this.destroyStream(broadcast)); dispatcher.on('error', () => this.destroyStream(broadcast)); dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + this.streams.set(broadcast, { dispatcher, input: broadcast }); broadcast.registerDispatcher(dispatcher); return dispatcher; } From 91fc6ccb5c938991739d76407c5e1e9be8f3b2f5 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 13:57:09 +0000 Subject: [PATCH 20/53] VoiceBroadcasting much more efficient --- src/client/voice/VoiceBroadcast.js | 106 +++++++++++++++--- .../voice/dispatcher/StreamDispatcher.js | 20 +++- test/voice.js | 1 + 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 892e0330b..eac7b7180 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -1,5 +1,7 @@ const EventEmitter = require('events').EventEmitter; const Prism = require('prism-media'); +const OpusEncoders = require('./opus/OpusEngineList'); +const Collection = require('../../util/Collection'); const ffmpegArguments = [ '-analyzeduration', '0', @@ -13,10 +15,42 @@ class VoiceBroadcast extends EventEmitter { constructor(client) { super(); this.client = client; - this.dispatchers = []; + this.dispatchers = new Collection(); this.prism = new Prism(); + this.opusEncoder = OpusEncoders.fetch(); this.currentTranscoder = null; this.tickInterval = null; + this._volume = 1; + } + + applyVolume(buffer, volume) { + volume = volume || this._volume; + if (volume === 1) return buffer; + + const out = new Buffer(buffer.length); + for (let i = 0; i < buffer.length; i += 2) { + if (i >= buffer.length - 1) break; + const uint = Math.min(32767, Math.max(-32767, Math.floor(volume * buffer.readInt16LE(i)))); + out.writeInt16LE(uint, i); + } + + return out; + } + + setVolume(volume) { + this._volume = volume; + } + + setVolumeDecibels(db) { + this.setVolume(Math.pow(10, db / 20)); + } + + setVolumeLogarithmic(value) { + this.setVolume(Math.pow(value, 1.660964)); + } + + get volume() { + return this._volume; } get _playableStream() { @@ -24,12 +58,30 @@ class VoiceBroadcast extends EventEmitter { return this.currentTranscoder.transcoder.output || this.currentTranscoder.options.stream; } + unregisterDispatcher(dispatcher, old) { + let container = this.dispatchers.get(old || dispatcher.volume); + if (container) { + if (container.delete(dispatcher)) return; + } + for (container of this.dispatchers.values()) { + container.delete(dispatcher); + } + } + registerDispatcher(dispatcher) { - if (!this.dispatchers.includes(dispatcher)) { - this.dispatchers.push(dispatcher); - dispatcher.once('end', () => { - const ind = this.dispatchers.indexOf(dispatcher); - if (ind > -1) this.dispatchers.splice(ind, 1); + if (!this.dispatchers.has(dispatcher.volume)) { + this.dispatchers.set(dispatcher.volume, new Set()); + } + const container = this.dispatchers.get(dispatcher.volume); + if (!container.has(dispatcher)) { + container.add(dispatcher); + dispatcher.once('end', () => this.unregisterDispatcher(dispatcher)); + dispatcher.on('volumeChange', (o, n) => { + this.unregisterDispatcher(dispatcher, o); + if (!this.dispatchers.has(n)) { + this.dispatchers.set(n, new Set()); + } + this.dispatchers.get(n).add(dispatcher); }); } } @@ -78,15 +130,19 @@ class VoiceBroadcast extends EventEmitter { } pause() { - for (const dispatcher of this.dispatchers) { - dispatcher.pause(); + for (const container of this.dispatchers.values()) { + for (const dispatcher of container.values()) { + dispatcher.pause(); + } } clearInterval(this.tickInterval); } resume() { - for (const dispatcher of this.dispatchers) { - dispatcher.resume(); + for (const container of this.dispatchers.values()) { + for (const dispatcher of container.values()) { + dispatcher.resume(); + } } this._startPlaying(); } @@ -99,17 +155,37 @@ class VoiceBroadcast extends EventEmitter { tick() { if (!this._playableStream) return; const stream = this._playableStream; - const buffer = stream.read(1920 * 2); + const bufferLength = 1920 * 2; + let buffer = stream.read(bufferLength); - for (const dispatcher of this.dispatchers) { - setImmediate(() => dispatcher.process(buffer, true)); + if (!buffer) return; + + if (buffer.length !== bufferLength) { + const newBuffer = new Buffer(bufferLength).fill(0); + buffer.copy(newBuffer); + buffer = newBuffer; + } + + buffer = this.applyVolume(buffer); + + for (const x of this.dispatchers.entries()) { + const [volume, container] = x; + if (container.size === 0) continue; + setImmediate(() => { + const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); + for (const dispatcher of container.values()) { + dispatcher.process(buffer, true, opusPacket); + } + }); } } end() { this.killCurrentTranscoder(); - for (const dispatcher of this.dispatchers) { - dispatcher.destroy('end', 'broadcast ended'); + for (const container of this.dispatchers.values()) { + for (const dispatcher of container.values()) { + dispatcher.destroy('end', 'broadcast ended'); + } } } } diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 7aed21ebc..e20d0545a 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -80,6 +80,7 @@ class StreamDispatcher extends EventEmitter { * @param {number} volume The volume that you want to set */ setVolume(volume) { + this.emit('volumeChange', this.streamOptions.volume, volume); this.streamOptions.volume = volume; } @@ -88,7 +89,7 @@ class StreamDispatcher extends EventEmitter { * @param {number} db The decibels */ setVolumeDecibels(db) { - this.streamOptions.volume = Math.pow(10, db / 20); + this.setVolume(Math.pow(10, db / 20)); } /** @@ -96,7 +97,7 @@ class StreamDispatcher extends EventEmitter { * @param {number} value The value for the volume */ setVolumeLogarithmic(value) { - this.streamOptions.volume = Math.pow(value, 1.660964); + this.setVolume(Math.pow(value, 1.660964)); } /** @@ -128,9 +129,10 @@ class StreamDispatcher extends EventEmitter { this.emit('speaking', value); } - sendBuffer(buffer, sequence, timestamp) { + sendBuffer(buffer, sequence, timestamp, opusPacket) { + opusPacket = opusPacket || this.player.opusEncoder.encode(buffer); let repeats = this.passes; - const packet = this.createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer)); + const packet = this.createPacket(sequence, timestamp, opusPacket); while (repeats--) { this.player.voiceConnection.sockets.udp.send(packet) .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); @@ -168,7 +170,7 @@ class StreamDispatcher extends EventEmitter { return out; } - process(buffer, controlled) { + process(buffer, controlled, packet) { try { if (this.destroyed) { this.setSpeaking(false); @@ -208,6 +210,14 @@ class StreamDispatcher extends EventEmitter { data.startTime = Date.now(); } + if (packet) { + data.count++; + data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0; + data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; + this.sendBuffer(null, data.sequence, data.timestamp, packet); + return; + } + const bufferLength = 1920 * data.channels; if (!controlled) { buffer = this.stream.read(bufferLength); diff --git a/test/voice.js b/test/voice.js index e41fd53b2..5a6ad1657 100644 --- a/test/voice.js +++ b/test/voice.js @@ -46,6 +46,7 @@ client.on('message', m => { const com = eval(m.content.split(' ').slice(1).join(' ')); m.channel.sendMessage(`\`\`\`\n${com}\`\`\``); } catch (e) { + console.log(e); m.channel.sendMessage(`\`\`\`\n${e}\`\`\``); } } From 22a6ded3416f8c61085c5222c093623da8819e15 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 14:27:59 +0000 Subject: [PATCH 21/53] Defer some creations --- src/client/voice/VoiceBroadcast.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index eac7b7180..d94c7046a 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -168,15 +168,16 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); + let count = 0; + for (const x of this.dispatchers.entries()) { const [volume, container] = x; if (container.size === 0) continue; - setImmediate(() => { - const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); - for (const dispatcher of container.values()) { - dispatcher.process(buffer, true, opusPacket); - } - }); + const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); + for (const dispatcher of container.values()) { + setTimeout(() => dispatcher.process(buffer, true, opusPacket), count); + count++; + } } } From 90ca422485371b8ef0e2e0408d8d018f6e4b5f06 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 14:30:57 +0000 Subject: [PATCH 22/53] undo that --- src/client/voice/VoiceBroadcast.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index d94c7046a..d8cbae113 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -168,6 +168,10 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); + setImmediate(() => this.process(buffer)); + } + + process(buffer) { let count = 0; for (const x of this.dispatchers.entries()) { From 0a6d71d7e53d89794ec2dc5e7a72929dbfea3d1a Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 14:45:43 +0000 Subject: [PATCH 23/53] try this fix idk --- src/client/voice/VoiceBroadcast.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index d8cbae113..aa8731bd5 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -168,19 +168,11 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); - setImmediate(() => this.process(buffer)); - } - - process(buffer) { - let count = 0; - for (const x of this.dispatchers.entries()) { const [volume, container] = x; - if (container.size === 0) continue; const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); for (const dispatcher of container.values()) { - setTimeout(() => dispatcher.process(buffer, true, opusPacket), count); - count++; + setImmediate(() => dispatcher.process(buffer, true, opusPacket)); } } } From fb1d0a3e74c1dae21d4e71b5bc1f1bdf521748a1 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 15:00:56 +0000 Subject: [PATCH 24/53] fix eslint --- src/client/voice/player/AudioPlayer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 22b5ae953..c6814de6d 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -3,7 +3,6 @@ const Prism = require('prism-media'); const StreamDispatcher = require('../dispatcher/StreamDispatcher'); const Collection = require('../../../util/Collection'); const OpusEncoders = require('../opus/OpusEngineList'); -const VoiceBroadcast = require('../VoiceBroadcast'); const ffmpegArguments = [ '-analyzeduration', '0', From 3109accf87d7a25f0893e7f4699e5f530794fb8a Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 15:25:28 +0000 Subject: [PATCH 25/53] improve broadcasting performance --- src/client/voice/VoiceBroadcast.js | 12 +++++++----- src/client/voice/dispatcher/StreamDispatcher.js | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index aa8731bd5..644203df4 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -169,11 +169,13 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); for (const x of this.dispatchers.entries()) { - const [volume, container] = x; - const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); - for (const dispatcher of container.values()) { - setImmediate(() => dispatcher.process(buffer, true, opusPacket)); - } + setImmediate(() => { + const [volume, container] = x; + const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); + for (const dispatcher of container.values()) { + setImmediate(() => dispatcher.process(buffer, true, opusPacket)); + } + }); } } diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index e20d0545a..ea75a3118 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -120,6 +120,7 @@ class StreamDispatcher extends EventEmitter { } setSpeaking(value) { + if (this.speaking === value) return; this.speaking = value; /** * Emitted when the dispatcher starts/stops speaking From f31a3725fe0d29fcdd22f9f39adf54fd6d743eef Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 17:25:28 +0000 Subject: [PATCH 26/53] StreamDispatcher documentation --- .../voice/dispatcher/StreamDispatcher.js | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index ea75a3118..bf283d513 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -19,7 +19,15 @@ nonce.fill(0); class StreamDispatcher extends EventEmitter { constructor(player, stream, streamOptions) { super(); + /** + * The Audio Player that controls this dispatcher + * @type {AudioPlayer} + */ this.player = player; + /** + * The stream that the dispatcher plays + * @type {ReadableStream|VoiceBroadcast} + */ this.stream = stream; if (!(this.stream instanceof VoiceBroadcast)) this.startStreaming(); this.streamOptions = streamOptions; @@ -34,16 +42,28 @@ class StreamDispatcher extends EventEmitter { * @type {boolean} */ this.paused = false; - + /** + * Whether this dispatcher has been destroyed + * @type {boolean} + */ this.destroyed = false; this.setVolume(streamOptions.volume || 1); } + /** + * How many passes the dispatcher should take when sending packets to reduce packet loss. Values over 5 + * aren't recommended, as it means you are using 5x more bandwidth. You _can_ edit this at runtime. + * @type {number} + */ get passes() { return this.streamOptions.passes || 1; } + set passes(n) { + this.streamOptions.passes = n; + } + get streamingData() { return this.player.streamingData; } @@ -80,6 +100,12 @@ class StreamDispatcher extends EventEmitter { * @param {number} volume The volume that you want to set */ setVolume(volume) { + /** + * Emitted when the volume of this dispatcher changes + * @param {number} oldVolume the old volume + * @param {number} newVolume the new volume + * @event StreamDispatcher#volumeChange + */ this.emit('volumeChange', this.streamOptions.volume, volume); this.streamOptions.volume = volume; } @@ -134,6 +160,11 @@ class StreamDispatcher extends EventEmitter { opusPacket = opusPacket || this.player.opusEncoder.encode(buffer); let repeats = this.passes; const packet = this.createPacket(sequence, timestamp, opusPacket); + /** + * Emitted whenever the dispatcher has debug information + * @event StreamDispatcher#debug + * @param {string} info the debug info + */ while (repeats--) { this.player.voiceConnection.sockets.udp.send(packet) .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); @@ -258,11 +289,20 @@ class StreamDispatcher extends EventEmitter { this.destroyed = true; this.setSpeaking(false); this.emit(type, reason); + /** + * Emitted once the dispatcher ends + * @param {string} [reason] the reason the dispatcher ended + * @event StreamDispatcher#end + */ if (type !== 'end') this.emit('end', `destroyed due to ${type} - ${reason}`); } startStreaming() { if (!this.stream) { + /** + * Emitted if the dispatcher encounters an error + * @param {string} error the error message + */ this.emit('error', 'No stream'); return; } From 221e7f8b216fe6a029fe96ae9c0a419ca15e1431 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 17:44:05 +0000 Subject: [PATCH 27/53] Voice Connection docs --- src/client/voice/VoiceConnection.js | 11 +++++++++++ src/client/voice/dispatcher/StreamDispatcher.js | 1 + 2 files changed, 12 insertions(+) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 60dc696e5..304769145 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -273,6 +273,17 @@ class VoiceConnection extends EventEmitter { return this.player.playPCMStream(stream, options); } + /** + * Plays a voice broadcast + * @param {VoiceBroadcast} broadcast the broadcast to play + * @returns {StreamDispatcher} + * @example + * // play a broadcast + * const broadcast = client + * .createVoiceBroadcast() + * .playFile('./test.mp3'); + * const dispatcher = voiceConnection.playBroadcast(broadcast); + */ playBroadcast(broadcast) { return this.player.playBroadcast(broadcast); } diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index bf283d513..5b14b88be 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -301,6 +301,7 @@ class StreamDispatcher extends EventEmitter { if (!this.stream) { /** * Emitted if the dispatcher encounters an error + * @event StreamDispatcher#error * @param {string} error the error message */ this.emit('error', 'No stream'); From 69ccc75590b15aa80f66bf3b6992305e522b99fc Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 17:56:58 +0000 Subject: [PATCH 28/53] more voice docs --- src/client/voice/VoiceBroadcast.js | 102 ++++++++++++++++++++++++- src/client/voice/player/AudioPlayer.js | 17 +++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 644203df4..7dc4be002 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -11,13 +11,33 @@ const ffmpegArguments = [ '-ac', '2', ]; +/** + * A voice broadcast that can be played across multiple voice connections + * @extends {EventEmitter} + */ class VoiceBroadcast extends EventEmitter { constructor(client) { super(); + /** + * The client that created the broadcast + * @type {Client} + */ this.client = client; this.dispatchers = new Collection(); + /** + * The audio transcoder that this broadcast uses + * @type {Prism} + */ this.prism = new Prism(); + /** + * The opus encoder that this broadcast uses + * @type {NodeOpusEngine|OpusScriptEngine} + */ this.opusEncoder = OpusEncoders.fetch(); + /** + * The current audio transcoder that is being used + * @type {object} + */ this.currentTranscoder = null; this.tickInterval = null; this._volume = 1; @@ -37,18 +57,35 @@ class VoiceBroadcast extends EventEmitter { return out; } + /** + * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. + * @param {number} volume The volume that you want to set + */ setVolume(volume) { this._volume = volume; } + /** + * Set the volume in decibels + * @param {number} db The decibels + */ setVolumeDecibels(db) { this.setVolume(Math.pow(10, db / 20)); } + /** + * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. + * @param {number} value The value for the volume + */ setVolumeLogarithmic(value) { this.setVolume(Math.pow(value, 1.660964)); } + /** + * The current volume of the broadcast + * @readonly + * @type {number} + */ get volume() { return this._volume; } @@ -93,12 +130,47 @@ class VoiceBroadcast extends EventEmitter { } } + /** + * Plays any audio stream across the broadcast + * @param {ReadableStream} stream The audio stream to play + * @param {StreamOptions} [options] Options for playing the stream + * @returns {VoiceBroadcast} + * @example + * // play streams using ytdl-core + * const ytdl = require('ytdl-core'); + * const streamOptions = { seek: 0, volume: 1 }; + * const broadcast = client.createVoiceBroadcast(); + * + * voiceChannel.join() + * .then(connection => { + * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', {filter : 'audioonly'}); + * broadcast.playStream(stream); + * const dispatcher = connection.playBroadcast(broadcast); + * }) + * .catch(console.error); + */ playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; options.stream = stream; return this._playTranscodable(stream, options); } + /** + * Play the given file in the voice connection. + * @param {string} file The path to the file + * @param {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + * @example + * // play files natively + * const broadcast = client.createVoiceBroadcast(); + * + * voiceChannel.join() + * .then(connection => { + * broadcast.playFile('C:/Users/Discord/Desktop/music.mp3'); + * const dispatcher = connection.playBroadcast(broadcast); + * }) + * .catch(console.error); + */ playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; return this._playTranscodable(file, options); @@ -111,7 +183,20 @@ class VoiceBroadcast extends EventEmitter { media, ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek)]), }); - transcoder.once('error', e => this.emit('error', e)); + /** + * Emitted whenever an error occurs + * @event VoiceBroadcast#error + * @param {Error} error the error that occurred + */ + transcoder.once('error', e => { + if (this.listenerCount('error') > 0) this.emit('error', e); + /** + * Emitted whenever the VoiceBroadcast has any warnings + * @event VoiceBroadcast#warn + * @param {string|Error} warning the warning that was raised + */ + else this.emit('warn', e); + }); transcoder.once('end', () => this.killCurrentTranscoder()); this.currentTranscoder = { transcoder, @@ -121,6 +206,12 @@ class VoiceBroadcast extends EventEmitter { return this; } + /** + * Plays a stream of 16-bit signed stereo PCM at 48KHz. + * @param {ReadableStream} stream The audio stream to play. + * @param {StreamOptions} [options] Options for playing the stream + * @returns {VoiceBroadcast} + */ playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { this.killCurrentTranscoder(); const options = { seek, volume, passes, stream }; @@ -129,6 +220,9 @@ class VoiceBroadcast extends EventEmitter { return this; } + /** + * Pauses the entire broadcast - all dispatchers also pause + */ pause() { for (const container of this.dispatchers.values()) { for (const dispatcher of container.values()) { @@ -138,6 +232,9 @@ class VoiceBroadcast extends EventEmitter { clearInterval(this.tickInterval); } + /** + * Resumes the entire broadcast - all dispatchers also resume + */ resume() { for (const container of this.dispatchers.values()) { for (const dispatcher of container.values()) { @@ -179,6 +276,9 @@ class VoiceBroadcast extends EventEmitter { } } + /** + * End the current broadcast, all subscribed dispatchers will also end + */ end() { this.killCurrentTranscoder(); for (const container of this.dispatchers.values()) { diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index c6814de6d..f67969f95 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -12,11 +12,28 @@ const ffmpegArguments = [ '-ac', '2', ]; +/** + * An Audio Player for a Voice Connection + * @private + * @extends {EventEmitter} + */ class AudioPlayer extends EventEmitter { constructor(voiceConnection) { super(); + /** + * The voice connection that the player serves + * @type {VoiceConnection} + */ this.voiceConnection = voiceConnection; + /** + * The prism transcoder that the player uses + * @type {Prism} + */ this.prism = new Prism(); + /** + * The opus encoder that the player uses + * @type {NodeOpusEngine|OpusScriptEngine} + */ this.opusEncoder = OpusEncoders.fetch(); this.streams = new Collection(); this.streamingData = { From d13c48bafaf1145b1a88756a9daa1f267575bd8f Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 18:10:48 +0000 Subject: [PATCH 29/53] more tiny voice docs --- src/client/Client.js | 4 ++++ src/client/voice/VoiceBroadcast.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/client/Client.js b/src/client/Client.js index 2e7d18157..4a68f7735 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -226,6 +226,10 @@ class Client extends EventEmitter { return typeof window !== 'undefined'; } + /** + * Creates a new voice broadcast + * @returns {VoiceBroadcast} the created broadcast + */ createVoiceBroadcast() { const broadcast = new VoiceBroadcast(this); this.broadcasts.push(broadcast); diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 7dc4be002..9509ece7a 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -12,7 +12,7 @@ const ffmpegArguments = [ ]; /** - * A voice broadcast that can be played across multiple voice connections + * A voice broadcast can be played across multiple voice connections for improved shared-stream efficiency. * @extends {EventEmitter} */ class VoiceBroadcast extends EventEmitter { From e2753136a415992edd5c5ad2d8da773e519265b4 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 18:21:22 +0000 Subject: [PATCH 30/53] Reorganise VoiceBroadcast dispatchers and also add new events --- src/client/voice/VoiceBroadcast.js | 60 +++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 9509ece7a..963342941 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -23,7 +23,7 @@ class VoiceBroadcast extends EventEmitter { * @type {Client} */ this.client = client; - this.dispatchers = new Collection(); + this._dispatchers = new Collection(); /** * The audio transcoder that this broadcast uses * @type {Prism} @@ -43,6 +43,18 @@ class VoiceBroadcast extends EventEmitter { this._volume = 1; } + /** + * An array of subscribed dispatchers + * @type {StreamDispatcher[]} + */ + get dispatchers() { + let d = []; + for (const container of this._dispatchers.values()) { + d = d.concat(Array.from(container.values())); + } + return d; + } + applyVolume(buffer, volume) { volume = volume || this._volume; if (volume === 1) return buffer; @@ -96,30 +108,42 @@ class VoiceBroadcast extends EventEmitter { } unregisterDispatcher(dispatcher, old) { - let container = this.dispatchers.get(old || dispatcher.volume); + /** + * Emitted whenever a Stream Dispatcher unsubscribes from the broadcast + * @event VoiceBroadcast#unsubscribe + * @param {dispatcher} the dispatcher that unsubscribed + */ + this.emit('unsubscribe', dispatcher); + let container = this._dispatchers.get(old || dispatcher.volume); if (container) { if (container.delete(dispatcher)) return; } - for (container of this.dispatchers.values()) { + for (container of this._dispatchers.values()) { container.delete(dispatcher); } } registerDispatcher(dispatcher) { - if (!this.dispatchers.has(dispatcher.volume)) { - this.dispatchers.set(dispatcher.volume, new Set()); + if (!this._dispatchers.has(dispatcher.volume)) { + this._dispatchers.set(dispatcher.volume, new Set()); } - const container = this.dispatchers.get(dispatcher.volume); + const container = this._dispatchers.get(dispatcher.volume); if (!container.has(dispatcher)) { container.add(dispatcher); dispatcher.once('end', () => this.unregisterDispatcher(dispatcher)); dispatcher.on('volumeChange', (o, n) => { this.unregisterDispatcher(dispatcher, o); - if (!this.dispatchers.has(n)) { - this.dispatchers.set(n, new Set()); + if (!this._dispatchers.has(n)) { + this._dispatchers.set(n, new Set()); } - this.dispatchers.get(n).add(dispatcher); + this._dispatchers.get(n).add(dispatcher); }); + /** + * Emitted whenever a stream dispatcher subscribes to the broadcast + * @event VoiceBroadcast#subscribe + * @param {StreamDispatcher} dispatcher the subscribed dispatcher + */ + this.emit('subscribe', dispatcher); } } @@ -197,7 +221,14 @@ class VoiceBroadcast extends EventEmitter { */ else this.emit('warn', e); }); - transcoder.once('end', () => this.killCurrentTranscoder()); + /** + * Emitted once the broadcast (the audio stream) ends + * @event VoiceBroadcast#end + */ + transcoder.once('end', () => { + this.emit('end'); + this.killCurrentTranscoder(); + }); this.currentTranscoder = { transcoder, options, @@ -214,6 +245,7 @@ class VoiceBroadcast extends EventEmitter { */ playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { this.killCurrentTranscoder(); + stream.once('end', () => this.emit('end')); const options = { seek, volume, passes, stream }; this.currentTranscoder = { options }; stream.once('readable', () => this._startPlaying()); @@ -224,7 +256,7 @@ class VoiceBroadcast extends EventEmitter { * Pauses the entire broadcast - all dispatchers also pause */ pause() { - for (const container of this.dispatchers.values()) { + for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.pause(); } @@ -236,7 +268,7 @@ class VoiceBroadcast extends EventEmitter { * Resumes the entire broadcast - all dispatchers also resume */ resume() { - for (const container of this.dispatchers.values()) { + for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.resume(); } @@ -265,7 +297,7 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); - for (const x of this.dispatchers.entries()) { + for (const x of this._dispatchers.entries()) { setImmediate(() => { const [volume, container] = x; const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); @@ -281,7 +313,7 @@ class VoiceBroadcast extends EventEmitter { */ end() { this.killCurrentTranscoder(); - for (const container of this.dispatchers.values()) { + for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.destroy('end', 'broadcast ended'); } From eacbfbd520565a5516de83fafb7062f14d48fb68 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 19:13:25 +0000 Subject: [PATCH 31/53] Add VoiceBroadcast#playArbitraryInput and VoiceConnection#playArbitraryInput --- src/client/voice/VoiceBroadcast.js | 11 +++++++++++ src/client/voice/VoiceConnection.js | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 963342941..bce054583 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -252,6 +252,17 @@ class VoiceBroadcast extends EventEmitter { return this; } + /** + * Play an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description) + * @param {string} input the arbitrary input + * @param {StreamOptions} [options] Options for playing the stream + * @returns {VoiceBroadcast} + */ + playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + return this.player.playUnknownStream(input, options); + } + /** * Pauses the entire broadcast - all dispatchers also pause */ diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 304769145..4a064a5c1 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -241,6 +241,17 @@ class VoiceConnection extends EventEmitter { return this.player.playUnknownStream(file, options); } + /** + * Play an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description) + * @param {string} input the arbitrary input + * @param {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + */ + playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + return this.player.playUnknownStream(input, options); + } + /** * Plays and converts an audio stream in the voice connection. * @param {ReadableStream} stream The audio stream to play From 91e0a81d6bc8deb80cf222a968ba57ef1a5751a4 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 19:20:32 +0000 Subject: [PATCH 32/53] Add playArbitraryInput --- src/client/voice/VoiceBroadcast.js | 4 ++-- src/client/voice/VoiceConnection.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index bce054583..92bbb414f 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -181,7 +181,7 @@ class VoiceBroadcast extends EventEmitter { /** * Play the given file in the voice connection. - * @param {string} file The path to the file + * @param {string} file The absolute path to the file * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} * @example @@ -197,7 +197,7 @@ class VoiceBroadcast extends EventEmitter { */ playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - return this._playTranscodable(file, options); + return this._playTranscodable(`file:${file}`, options); } _playTranscodable(media, options) { diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 4a064a5c1..c0c6e43b5 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -225,7 +225,7 @@ class VoiceConnection extends EventEmitter { /** * Play the given file in the voice connection. - * @param {string} file The path to the file + * @param {string} file The absolute path to the file * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} * @example @@ -238,7 +238,7 @@ class VoiceConnection extends EventEmitter { */ playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { const options = { seek, volume, passes }; - return this.player.playUnknownStream(file, options); + return this.player.playUnknownStream(`file:${file}`, options); } /** From 8d8ac78e68c54a1c0238ded6ef2288e865749dfd Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Fri, 30 Dec 2016 21:12:13 +0000 Subject: [PATCH 33/53] start voice topics --- docs/index.yml | 4 ++ docs/topics/voice.md | 109 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 docs/topics/voice.md diff --git a/docs/index.yml b/docs/index.yml index 4bf13c7e3..31de68b3a 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -6,6 +6,10 @@ path: updating.md - name: FAQ path: faq.md +- name: Topics + files: + - name: Voice + path: voice.md - name: Examples files: - name: Ping diff --git a/docs/topics/voice.md b/docs/topics/voice.md new file mode 100644 index 000000000..24fa405b0 --- /dev/null +++ b/docs/topics/voice.md @@ -0,0 +1,109 @@ +# Introduction to Voice +Voice in discord.js can be used for many things, such as music bots, recording or relaying audio. + +In discord.js, you can use voice by connecting to a `VoiceChannel` to obtain a `VoiceConnection`, where you can start streaming and receiving audio. + +To get started, make sure you have: +* ffmpeg - `npm install --global ffmpeg-binaries` +* an opus encoder, choose one from below: + * `npm install opusscript` + * `npm install node-opus` +* a good network connection + +## Joining a voice channel +The example below reacts to a message and joins the sender's voice channel, catching any errors. This is important +as it allows us to obtain a `VoiceConnection` that we can start to stream audio with + +```js +const Discord = require('discord.js'); +const client = new Discord.Client(); + +client.login('token here'); + +client.on('message', message => { + // voice only works in guilds, if the message does not come from a guild, + // we ignore it + if (!message.guild) return; + + if (message.content === '/join') { + // only try to join the sender's voice channel if they are in one themselves + if (message.member.voiceChannel) { + message.member.voiceChannel.join() + .then(connection => { // connection is an instance of VoiceConnection + message.reply('I have successfully connected to the channel!'); + }) + .catch(console.log); + } else { + message.reply('You need to join a voice channel first!'); + } + } +}); +``` + +## Streaming to a Voice Channel +In the previous example, we looked at how to join a voice channel in order to obtain a `VoiceConnection`. Now that we +have obtained a voice connection, we can start streaming audio to it. The following example shows how to stream an mp3 +file: + +**Playing a file:** +```js +// to play a file, we need to give an absolute path to it +const dispatcher = connection.playFile('C:/Users/Discord/Desktop/myfile.mp3'); +``` + +Your file doesn't have to be just an mp3; ffmpeg can convert videos and audios of many formats. + +The `dispatcher` variable is an instance of a `StreamDispatcher`, which manages streaming a specific resource to a voice +channel. We can do many things with the dispatcher, such as finding out when the stream ends or changing the volume: + +```js +dispatcher.on('end', () => { + // the song has finished +}); + +dispatcher.on('error', e => { + // catch any errors that may arise + console.log(e); +}); + +dispatcher.setVolume(0.5); // set the volume to 50% +dispatcher.setVolume(1); // set the volume back to 100% + +console.log(dispatcher.time); // the time in milliseconds that the stream dispatcher has been playing for + +dispatcher.pause(); // pause the stream +dispatcher.resume(); // carry on playing + +dispatcher.end(); // end the dispatcher, emits 'end' event +``` + +If you have an existing [ReadableStream](https://nodejs.org/api/stream.html#stream_readable_streams), +this can also be used: + +**Playing a ReadableStream:** +```js +connection.playStream(myReadableStream); + +// if you don't want to use absolute paths, you can use +// fs.createReadStream to circumvent it + +const fs = require('fs'); +const stream = fs.createReadStream('./test.mp3'); +connection.playStream(stream); +``` + +It's important to note that creating a readable stream to a file is less efficient than simply using `connection.playFile()`. + +**Playing anything else:** + +For anything else, such as a URL to a file, you can use `connection.playArbitraryInput()`. You should consult the [ffmpeg protocol documentation](https://ffmpeg.org/ffmpeg-protocols.html) to see what you can use this for. + +```js +// play an mp3 from a URL +connection.playArbitraryInput('http://mysite.com/sound.mp3'); +``` + +Again, playing a file from a URL like this is more performant than creating a ReadableStream to the file. + +## Advanced Topics +soon:tm: \ No newline at end of file From bace8bcac2f4878db4be61a71442b03660e0f6cd Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 19:38:05 +0000 Subject: [PATCH 34/53] make broadcast not suck as much --- src/client/voice/VoiceBroadcast.js | 44 ++++++++++++++----- .../voice/dispatcher/StreamDispatcher.js | 10 +++-- test/voice.js | 8 ++++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 92bbb414f..820902b53 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -267,38 +267,59 @@ class VoiceBroadcast extends EventEmitter { * Pauses the entire broadcast - all dispatchers also pause */ pause() { + this.paused = true; for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.pause(); } } - clearInterval(this.tickInterval); } /** * Resumes the entire broadcast - all dispatchers also resume */ resume() { + this.paused = false; for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.resume(); } } - this._startPlaying(); } _startPlaying() { if (this.tickInterval) clearInterval(this.tickInterval); - this.tickInterval = this.client.setInterval(this.tick.bind(this), 20); + // this.tickInterval = this.client.setInterval(this.tick.bind(this), 20); + this._startTime = Date.now(); + this._count = 0; + this._pausedTime = 0; + this._missed = 0; + this.tick(); } tick() { if (!this._playableStream) return; + if (this.paused) { + this._pausedTime += 20; + setTimeout(() => this.tick(), 20); + return; + } const stream = this._playableStream; const bufferLength = 1920 * 2; let buffer = stream.read(bufferLength); - if (!buffer) return; + if (!buffer) { + this._missed++; + if (this._missed < 5) { + this._pausedTime += 200; + setTimeout(() => this.tick(), 200); + } else { + this.end(); + } + return; + } + + this._missed = 0; if (buffer.length !== bufferLength) { const newBuffer = new Buffer(bufferLength).fill(0); @@ -309,14 +330,15 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); for (const x of this._dispatchers.entries()) { - setImmediate(() => { - const [volume, container] = x; - const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); - for (const dispatcher of container.values()) { - setImmediate(() => dispatcher.process(buffer, true, opusPacket)); - } - }); + const [volume, container] = x; + const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); + for (const dispatcher of container.values()) { + dispatcher.process(buffer, true, opusPacket); + } } + const next = 20 + (this._startTime + this._pausedTime + (this._count * 20) - Date.now()); + this._count++; + setTimeout(() => this.tick(), next); } /** diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 5b14b88be..a2d15046c 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -158,13 +158,18 @@ class StreamDispatcher extends EventEmitter { sendBuffer(buffer, sequence, timestamp, opusPacket) { opusPacket = opusPacket || this.player.opusEncoder.encode(buffer); - let repeats = this.passes; const packet = this.createPacket(sequence, timestamp, opusPacket); + this.sendPacket(packet); + } + + sendPacket(packet) { + let repeats = this.passes; /** * Emitted whenever the dispatcher has debug information * @event StreamDispatcher#debug * @param {string} info the debug info */ + this.setSpeaking(true); while (repeats--) { this.player.voiceConnection.sockets.udp.send(packet) .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); @@ -183,7 +188,6 @@ class StreamDispatcher extends EventEmitter { packetBuffer.copy(nonce, 0, 0, 12); buffer = NaCl.secretbox(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key); - for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i]; return packetBuffer; @@ -231,8 +235,6 @@ class StreamDispatcher extends EventEmitter { return; } - this.setSpeaking(true); - if (!data.startTime) { /** * Emitted once the dispatcher starts streaming diff --git a/test/voice.js b/test/voice.js index 5a6ad1657..396bcc490 100644 --- a/test/voice.js +++ b/test/voice.js @@ -41,6 +41,13 @@ client.on('message', m => { } doQueue(connData); } + } else if (m.content.startsWith('/skip')) { + if (connections.has(m.guild.id)) { + const connData = connections.get(m.guild.id); + if (connData.dispatcher) { + connData.dispatcher.end(); + } + } } else if (m.content.startsWith('#eval') && m.author.id === '66564597481480192') { try { const com = eval(m.content.split(' ').slice(1).join(' ')); @@ -67,4 +74,5 @@ function doQueue(connData) { doQueue(connData); }); dispatcher.on('error', (...e) => console.log('dispatcher', ...e)); + connData.dispatcher = dispatcher; } From fcd7cf14502851908d8ad9e1df4aca36e882a8b2 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 19:44:42 +0000 Subject: [PATCH 35/53] stop destructive ending of broadcasts --- src/client/voice/VoiceBroadcast.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 820902b53..9851457f1 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -314,7 +314,7 @@ class VoiceBroadcast extends EventEmitter { this._pausedTime += 200; setTimeout(() => this.tick(), 200); } else { - this.end(); + this.killCurrentTranscoder(); } return; } From dc640017cdaf732fdb544e9e40e0db1cbba308e3 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 21:10:46 +0000 Subject: [PATCH 36/53] Fix some stream bugs --- src/client/voice/VoiceBroadcast.js | 7 ++----- src/client/voice/VoiceConnection.js | 4 ++-- src/client/voice/dispatcher/StreamDispatcher.js | 5 ++++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 9851457f1..003da16b9 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -151,6 +151,7 @@ class VoiceBroadcast extends EventEmitter { if (this.currentTranscoder) { if (this.currentTranscoder.transcoder) this.currentTranscoder.transcoder.kill(); this.currentTranscoder = null; + this.emit('end'); } } @@ -225,10 +226,7 @@ class VoiceBroadcast extends EventEmitter { * Emitted once the broadcast (the audio stream) ends * @event VoiceBroadcast#end */ - transcoder.once('end', () => { - this.emit('end'); - this.killCurrentTranscoder(); - }); + transcoder.once('end', () => this.killCurrentTranscoder()); this.currentTranscoder = { transcoder, options, @@ -245,7 +243,6 @@ class VoiceBroadcast extends EventEmitter { */ playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { this.killCurrentTranscoder(); - stream.once('end', () => this.emit('end')); const options = { seek, volume, passes, stream }; this.currentTranscoder = { options }; stream.once('readable', () => this._startPlaying()); diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index c0c6e43b5..f90fec911 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -160,7 +160,7 @@ class VoiceConnection extends EventEmitter { this.sockets.udp = new VoiceUDP(this); this.sockets.ws.on('error', e => this.emit('error', e)); this.sockets.udp.on('error', e => this.emit('error', e)); - this.sockets.ws.once('ready', d => { + this.sockets.ws.on('ready', d => { this.authentication.port = d.port; this.authentication.ssrc = d.ssrc; /** @@ -173,7 +173,7 @@ class VoiceConnection extends EventEmitter { this.sockets.udp.createUDPSocket(address); }, e => this.emit('error', e)); }); - this.sockets.ws.once('sessionDescription', (mode, secret) => { + this.sockets.ws.on('sessionDescription', (mode, secret) => { this.authentication.encryptionMode = mode; this.authentication.secretKey = secret; /** diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index a2d15046c..3edb759f5 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -172,7 +172,10 @@ class StreamDispatcher extends EventEmitter { this.setSpeaking(true); while (repeats--) { this.player.voiceConnection.sockets.udp.send(packet) - .catch(e => this.emit('debug', `Failed to send a packet ${e}`)); + .catch(e => { + this.setSpeaking(false); + this.emit('debug', `Failed to send a packet ${e}`); + }); } } From 0df17b16349b0ffb49df8674a8c9df268de08af7 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 21:39:12 +0000 Subject: [PATCH 37/53] Add broadcast.destroy(); --- src/client/voice/VoiceBroadcast.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 003da16b9..c977dda28 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -339,10 +339,17 @@ class VoiceBroadcast extends EventEmitter { } /** - * End the current broadcast, all subscribed dispatchers will also end + * Stop the current stream from playing without unsubscribing dispatchers. */ end() { this.killCurrentTranscoder(); + } + + /** + * End the current broadcast, all subscribed dispatchers will also end + */ + destroy() { + this.end(); for (const container of this._dispatchers.values()) { for (const dispatcher of container.values()) { dispatcher.destroy('end', 'broadcast ended'); From de0ba9fb7cda4607f50549a0a0033351397cca1c Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 21:48:30 +0000 Subject: [PATCH 38/53] change error to debug --- src/client/voice/VoiceWebSocket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/voice/VoiceWebSocket.js b/src/client/voice/VoiceWebSocket.js index bafa5dde3..d0996ff97 100644 --- a/src/client/voice/VoiceWebSocket.js +++ b/src/client/voice/VoiceWebSocket.js @@ -67,7 +67,7 @@ class VoiceWebSocket extends EventEmitter { if (this.dead) return; if (this.ws) this.reset(); if (this.attempts > 5) { - this.emit('error', new Error(`Too many connection attempts (${this.attempts}).`)); + this.emit('debug', new Error(`Too many connection attempts (${this.attempts}).`)); return; } From 9f8289e4334831ef62670ec0d386998c412305d5 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 22:09:51 +0000 Subject: [PATCH 39/53] don't process empty sets --- src/client/voice/VoiceBroadcast.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index c977dda28..c2e373b36 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -327,6 +327,7 @@ class VoiceBroadcast extends EventEmitter { buffer = this.applyVolume(buffer); for (const x of this._dispatchers.entries()) { + if (x[1].size === 0) continue; const [volume, container] = x; const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); for (const dispatcher of container.values()) { From 3b1264ad6dbd7edfc5ea047d2ef8ae2f028226bb Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Sat, 7 Jan 2017 22:30:20 +0000 Subject: [PATCH 40/53] Fix Buffer deprecation stuff --- src/client/voice/VoiceBroadcast.js | 7 +++---- src/client/voice/VoiceUDPClient.js | 4 ++-- src/client/voice/dispatcher/StreamDispatcher.js | 8 ++++---- src/client/voice/receiver/VoiceReceiver.js | 4 ++-- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index c2e373b36..96965c954 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -55,11 +55,10 @@ class VoiceBroadcast extends EventEmitter { return d; } - applyVolume(buffer, volume) { - volume = volume || this._volume; + applyVolume(buffer, volume = this._volume) { if (volume === 1) return buffer; - const out = new Buffer(buffer.length); + const out = Buffer.alloc(buffer.length); for (let i = 0; i < buffer.length; i += 2) { if (i >= buffer.length - 1) break; const uint = Math.min(32767, Math.max(-32767, Math.floor(volume * buffer.readInt16LE(i)))); @@ -319,7 +318,7 @@ class VoiceBroadcast extends EventEmitter { this._missed = 0; if (buffer.length !== bufferLength) { - const newBuffer = new Buffer(bufferLength).fill(0); + const newBuffer = Buffer.alloc(bufferLength).fill(0); buffer.copy(newBuffer); buffer = newBuffer; } diff --git a/src/client/voice/VoiceUDPClient.js b/src/client/voice/VoiceUDPClient.js index b7b0c0cfe..7fc2ca577 100644 --- a/src/client/voice/VoiceUDPClient.js +++ b/src/client/voice/VoiceUDPClient.js @@ -124,7 +124,7 @@ class VoiceConnectionUDPClient extends EventEmitter { }); }); - const blankMessage = new Buffer(70); + const blankMessage = Buffer.alloc(70); blankMessage.writeUIntBE(this.voiceConnection.authentication.ssrc, 0, 4); this.send(blankMessage); } @@ -132,7 +132,7 @@ class VoiceConnectionUDPClient extends EventEmitter { function parseLocalPacket(message) { try { - const packet = new Buffer(message); + const packet = Buffer.from(message); let address = ''; for (let i = 4; i < packet.indexOf(0, i); i++) address += String.fromCharCode(packet[i]); const port = parseInt(packet.readUIntLE(packet.length - 2, 2).toString(10), 10); diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 3edb759f5..0fa0f2368 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -2,7 +2,7 @@ const EventEmitter = require('events').EventEmitter; const NaCl = require('tweetnacl'); const VoiceBroadcast = require('../VoiceBroadcast'); -const nonce = new Buffer(24); +const nonce = Buffer.alloc(24); nonce.fill(0); /** @@ -180,7 +180,7 @@ class StreamDispatcher extends EventEmitter { } createPacket(sequence, timestamp, buffer) { - const packetBuffer = new Buffer(buffer.length + 28); + const packetBuffer = Buffer.alloc(buffer.length + 28); packetBuffer.fill(0); packetBuffer[0] = 0x80; packetBuffer[1] = 0x78; @@ -199,7 +199,7 @@ class StreamDispatcher extends EventEmitter { applyVolume(buffer) { if (this.volume === 1) return buffer; - const out = new Buffer(buffer.length); + const out = Buffer.alloc(buffer.length); for (let i = 0; i < buffer.length; i += 2) { if (i >= buffer.length - 1) break; const uint = Math.min(32767, Math.max(-32767, Math.floor(this.volume * buffer.readInt16LE(i)))); @@ -269,7 +269,7 @@ class StreamDispatcher extends EventEmitter { data.missed = 0; if (buffer.length !== bufferLength) { - const newBuffer = new Buffer(bufferLength).fill(0); + const newBuffer = Buffer.alloc(bufferLength).fill(0); buffer.copy(newBuffer); buffer = newBuffer; } diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index bc9156f2f..697e8df12 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -2,7 +2,7 @@ const EventEmitter = require('events').EventEmitter; const NaCl = require('tweetnacl'); const Readable = require('./VoiceReadable'); -const nonce = new Buffer(24); +const nonce = Buffer.alloc(24); nonce.fill(0); /** @@ -127,7 +127,7 @@ class VoiceReceiver extends EventEmitter { this.emit('warn', 'Failed to decrypt voice packet'); return; } - data = new Buffer(data); + data = Buffer.from(data); if (this.opusStreams.get(user.id)) this.opusStreams.get(user.id)._push(data); /** * Emitted whenever voice data is received from the voice connection. This is _always_ emitted (unlike PCM). From adbd95adf094c4c098ff3fc4c8ecbf28a3f2947f Mon Sep 17 00:00:00 2001 From: Programmix Date: Sun, 8 Jan 2017 00:39:48 -0800 Subject: [PATCH 41/53] Fix VoiceBroadcaster per-dispatcher volume (#1073) --- src/client/voice/VoiceBroadcast.js | 45 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index c2e373b36..04c92902f 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -24,16 +24,12 @@ class VoiceBroadcast extends EventEmitter { */ this.client = client; this._dispatchers = new Collection(); + this._encoders = new Collection(); /** * The audio transcoder that this broadcast uses * @type {Prism} */ this.prism = new Prism(); - /** - * The opus encoder that this broadcast uses - * @type {NodeOpusEngine|OpusScriptEngine} - */ - this.opusEncoder = OpusEncoders.fetch(); /** * The current audio transcoder that is being used * @type {object} @@ -108,24 +104,28 @@ class VoiceBroadcast extends EventEmitter { } unregisterDispatcher(dispatcher, old) { + const volume = old || dispatcher.volume; + /** * Emitted whenever a Stream Dispatcher unsubscribes from the broadcast * @event VoiceBroadcast#unsubscribe * @param {dispatcher} the dispatcher that unsubscribed */ this.emit('unsubscribe', dispatcher); - let container = this._dispatchers.get(old || dispatcher.volume); - if (container) { - if (container.delete(dispatcher)) return; - } - for (container of this._dispatchers.values()) { + for (const container of this._dispatchers.values()) { container.delete(dispatcher); + + if (!container.size) { + this._dispatchers.delete(volume); + this._encoders.delete(volume); + } } } registerDispatcher(dispatcher) { if (!this._dispatchers.has(dispatcher.volume)) { this._dispatchers.set(dispatcher.volume, new Set()); + this._encoders.set(dispatcher.volume, OpusEncoders.fetch()); } const container = this._dispatchers.get(dispatcher.volume); if (!container.has(dispatcher)) { @@ -135,6 +135,7 @@ class VoiceBroadcast extends EventEmitter { this.unregisterDispatcher(dispatcher, o); if (!this._dispatchers.has(n)) { this._dispatchers.set(n, new Set()); + this._encoders.set(n, OpusEncoders.fetch()); } this._dispatchers.get(n).add(dispatcher); }); @@ -324,16 +325,24 @@ class VoiceBroadcast extends EventEmitter { buffer = newBuffer; } - buffer = this.applyVolume(buffer); + let packetMatrix = {}; - for (const x of this._dispatchers.entries()) { - if (x[1].size === 0) continue; - const [volume, container] = x; - const opusPacket = this.opusEncoder.encode(this.applyVolume(buffer, volume)); - for (const dispatcher of container.values()) { - dispatcher.process(buffer, true, opusPacket); - } + const getOpusPacket = (volume) => { + if (packetMatrix[volume]) return packetMatrix[volume]; + + const opusEncoder = this._encoders.get(volume); + const opusPacket = opusEncoder.encode(this.applyVolume(buffer, this._volume * volume)); + packetMatrix[volume] = opusPacket; + return opusPacket; + }; + + for (const dispatcher of this.dispatchers) { + const volume = dispatcher.volume; + setImmediate(() => { + dispatcher.process(buffer, true, getOpusPacket(volume)); + }); } + const next = 20 + (this._startTime + this._pausedTime + (this._count * 20) - Date.now()); this._count++; setTimeout(() => this.tick(), next); From b5026909a1a20d2b68faefe775c9ae4e56b8a0d4 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Mon, 9 Jan 2017 18:20:05 +0000 Subject: [PATCH 42/53] Remove DMChannel.bulkDelete and GroupDMChannel.bulkDelete --- src/structures/DMChannel.js | 4 ++-- src/structures/GroupDMChannel.js | 4 ++-- src/structures/interface/TextBasedChannel.js | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 6af543dd6..93f277dcc 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -52,10 +52,10 @@ class DMChannel extends Channel { get typingCount() { return; } createCollector() { return; } awaitMessages() { return; } - bulkDelete() { return; } + // doesn't work on DM channels; bulkDelete() { return; } _cacheMessage() { return; } } -TextBasedChannel.applyToClass(DMChannel, true); +TextBasedChannel.applyToClass(DMChannel, true, ['bulkDelete']); module.exports = DMChannel; diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js index cf6739fd3..48ef131c9 100644 --- a/src/structures/GroupDMChannel.js +++ b/src/structures/GroupDMChannel.js @@ -136,10 +136,10 @@ class GroupDMChannel extends Channel { get typingCount() { return; } createCollector() { return; } awaitMessages() { return; } - bulkDelete() { return; } + // doesn't work on group DMs; bulkDelete() { return; } _cacheMessage() { return; } } -TextBasedChannel.applyToClass(GroupDMChannel, true); +TextBasedChannel.applyToClass(GroupDMChannel, true, ['bulkDelete']); module.exports = GroupDMChannel; diff --git a/src/structures/interface/TextBasedChannel.js b/src/structures/interface/TextBasedChannel.js index 5e06cb543..829eef683 100644 --- a/src/structures/interface/TextBasedChannel.js +++ b/src/structures/interface/TextBasedChannel.js @@ -372,7 +372,7 @@ class TextBasedChannel { } } -exports.applyToClass = (structure, full = false) => { +exports.applyToClass = (structure, full = false, ignore = []) => { const props = ['send', 'sendMessage', 'sendEmbed', 'sendFile', 'sendCode']; if (full) { props.push( @@ -391,6 +391,7 @@ exports.applyToClass = (structure, full = false) => { ); } for (const prop of props) { + if (ignore.includes(prop)) continue; Object.defineProperty(structure.prototype, prop, Object.getOwnPropertyDescriptor(TextBasedChannel.prototype, prop)); } }; From 9a6cb6477d3336a1aa56836c8ffdaca972a7113b Mon Sep 17 00:00:00 2001 From: Programmix Date: Thu, 26 Jan 2017 13:23:00 -0800 Subject: [PATCH 43/53] VoiceReceiver: multiple streams fix (#1132) * VoiceReceiver: multiple streams fix silly hydar... you can't have one Opus engine instance for every stream * Better creation of opus engine --- src/client/voice/receiver/VoiceReceiver.js | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 697e8df12..16395711d 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -1,6 +1,7 @@ const EventEmitter = require('events').EventEmitter; const NaCl = require('tweetnacl'); const Readable = require('./VoiceReadable'); +const OpusEncoders = require('../opus/OpusEngineList'); const nonce = Buffer.alloc(24); nonce.fill(0); @@ -25,6 +26,7 @@ class VoiceReceiver extends EventEmitter { this.queues = new Map(); this.pcmStreams = new Map(); this.opusStreams = new Map(); + this.opusEncoders = new Map(); /** * Whether or not this receiver has been destroyed. @@ -74,13 +76,16 @@ class VoiceReceiver extends EventEmitter { */ destroy() { this.voiceConnection.sockets.udp.socket.removeListener('message', this._listener); - for (const stream of this.pcmStreams) { - stream[1]._push(null); - this.pcmStreams.delete(stream[0]); + for (const [id, stream] of this.pcmStreams) { + stream._push(null); + this.pcmStreams.delete(id); } - for (const stream of this.opusStreams) { - stream[1]._push(null); - this.opusStreams.delete(stream[0]); + for (const [id, stream] of this.opusStreams) { + stream._push(null); + this.opusStreams.delete(id); + } + for (const [id] of this.opusEncoders) { + this.opusEncoders.delete(id); } this.destroyed = true; } @@ -137,6 +142,10 @@ class VoiceReceiver extends EventEmitter { */ this.emit('opus', user, data); if (this.listenerCount('pcm') > 0 || this.pcmStreams.size > 0) { + if (!this.opusEncoders.get(user.id)) this.opusEncoders.set(user.id, OpusEncoders.fetch()); + const pcm = this.opusEncoders.get(user.id).decode(data); + if (this.pcmStreams.get(user.id)) this.pcmStreams.get(user.id)._push(pcm); + /** * Emits decoded voice data when it's received. For performance reasons, the decoding will only * happen if there is at least one `pcm` listener on this receiver. @@ -144,8 +153,6 @@ class VoiceReceiver extends EventEmitter { * @param {User} user The user that is sending the buffer (is speaking) * @param {Buffer} buffer The decoded buffer */ - const pcm = this.voiceConnection.player.opusEncoder.decode(data); - if (this.pcmStreams.get(user.id)) this.pcmStreams.get(user.id)._push(pcm); this.emit('pcm', user, pcm); } } From 6fae17912e03d984926c1908e1208cba328cc31e Mon Sep 17 00:00:00 2001 From: Fiddlekins Date: Sun, 29 Jan 2017 18:59:00 +0000 Subject: [PATCH 44/53] Added try catch so that decode errors aren't fatal (#1146) * Added try catch so that decode errors aren't fatal * Tweaked the usage of the warn event and updated jsdocs * Moved method into class for webpack scoping reasons --- src/client/voice/receiver/VoiceReceiver.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 16395711d..5aec6d35b 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -125,11 +125,14 @@ class VoiceReceiver extends EventEmitter { let data = NaCl.secretbox.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key); if (!data) { /** - * Emitted whenever a voice packet cannot be decrypted + * Emitted whenever a voice packet experiences a problem. * @event VoiceReceiver#warn + * @param {string} reason The reason for the warning. If it happened because the voice packet could not be + * decrypted, this would be `decrypt`. If it happened because the voice packet could not be decoded into + * PCM, this would be `decode`. * @param {string} message The warning message */ - this.emit('warn', 'Failed to decrypt voice packet'); + this.emit('warn', 'decrypt', 'Failed to decrypt voice packet'); return; } data = Buffer.from(data); @@ -143,9 +146,12 @@ class VoiceReceiver extends EventEmitter { this.emit('opus', user, data); if (this.listenerCount('pcm') > 0 || this.pcmStreams.size > 0) { if (!this.opusEncoders.get(user.id)) this.opusEncoders.set(user.id, OpusEncoders.fetch()); - const pcm = this.opusEncoders.get(user.id).decode(data); + const { pcm, error } = VoiceReceiver._tryDecode(this.opusEncoders.get(user.id), data); + if (error) { + this.emit('warn', 'decode', `Failed to decode packet voice to PCM because: ${error.message}`); + return; + } if (this.pcmStreams.get(user.id)) this.pcmStreams.get(user.id)._push(pcm); - /** * Emits decoded voice data when it's received. For performance reasons, the decoding will only * happen if there is at least one `pcm` listener on this receiver. @@ -156,6 +162,14 @@ class VoiceReceiver extends EventEmitter { this.emit('pcm', user, pcm); } } + + static _tryDecode(encoder, data) { + try { + return { pcm: encoder.decode(data) }; + } catch (error) { + return { error }; + } + } } module.exports = VoiceReceiver; From 7ed58f5f7f004ccded83f432a4d3a4943d96c3af Mon Sep 17 00:00:00 2001 From: Programmix Date: Sun, 29 Jan 2017 11:07:33 -0800 Subject: [PATCH 45/53] Added Opus stream support, added volume interface (#1102) * Added opus stream support, added volume interface * Remove setImmediate * Fix weird syntax error * Most useless commit ever You're welcome, @PgBiel * Fix potential memory leak with OpusScript Emscripten has the tendency to not free resources even when the Opus engine instance has been garbage collected. Thanks to @abalabahaha for pointing this out. * Typo * VoiceReceiver.destroy: destroy opus encoder --- src/client/voice/VoiceBroadcast.js | 126 ++++++------ src/client/voice/VoiceConnection.js | 26 ++- .../voice/dispatcher/StreamDispatcher.js | 189 ++++++++---------- src/client/voice/opus/BaseOpusEngine.js | 4 + src/client/voice/opus/OpusEngineList.js | 6 +- src/client/voice/opus/OpusScriptEngine.js | 9 +- src/client/voice/player/AudioPlayer.js | 32 ++- src/client/voice/receiver/VoiceReceiver.js | 3 +- src/client/voice/util/VolumeInterface.js | 57 ++++++ 9 files changed, 262 insertions(+), 190 deletions(-) create mode 100644 src/client/voice/util/VolumeInterface.js diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index e7f74b48f..316c150f2 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -1,4 +1,4 @@ -const EventEmitter = require('events').EventEmitter; +const VolumeInterface = require('./util/VolumeInterface'); const Prism = require('prism-media'); const OpusEncoders = require('./opus/OpusEngineList'); const Collection = require('../../util/Collection'); @@ -15,7 +15,7 @@ const ffmpegArguments = [ * A voice broadcast can be played across multiple voice connections for improved shared-stream efficiency. * @extends {EventEmitter} */ -class VoiceBroadcast extends EventEmitter { +class VoiceBroadcast extends VolumeInterface { constructor(client) { super(); /** @@ -51,55 +51,12 @@ class VoiceBroadcast extends EventEmitter { return d; } - applyVolume(buffer, volume = this._volume) { - if (volume === 1) return buffer; - - const out = Buffer.alloc(buffer.length); - for (let i = 0; i < buffer.length; i += 2) { - if (i >= buffer.length - 1) break; - const uint = Math.min(32767, Math.max(-32767, Math.floor(volume * buffer.readInt16LE(i)))); - out.writeInt16LE(uint, i); - } - - return out; - } - - /** - * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. - * @param {number} volume The volume that you want to set - */ - setVolume(volume) { - this._volume = volume; - } - - /** - * Set the volume in decibels - * @param {number} db The decibels - */ - setVolumeDecibels(db) { - this.setVolume(Math.pow(10, db / 20)); - } - - /** - * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. - * @param {number} value The value for the volume - */ - setVolumeLogarithmic(value) { - this.setVolume(Math.pow(value, 1.660964)); - } - - /** - * The current volume of the broadcast - * @readonly - * @type {number} - */ - get volume() { - return this._volume; - } - get _playableStream() { - if (!this.currentTranscoder) return null; - return this.currentTranscoder.transcoder.output || this.currentTranscoder.options.stream; + const currentTranscoder = this.currentTranscoder; + if (!currentTranscoder) return null; + const transcoder = currentTranscoder.transcoder; + const options = currentTranscoder.options; + return (transcoder && transcoder.output) || options.stream; } unregisterDispatcher(dispatcher, old) { @@ -115,6 +72,7 @@ class VoiceBroadcast extends EventEmitter { container.delete(dispatcher); if (!container.size) { + this._encoders.get(volume).destroy(); this._dispatchers.delete(volume); this._encoders.delete(volume); } @@ -175,8 +133,7 @@ class VoiceBroadcast extends EventEmitter { * .catch(console.error); */ playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - options.stream = stream; + const options = { seek, volume, passes, stream }; return this._playTranscodable(stream, options); } @@ -202,6 +159,8 @@ class VoiceBroadcast extends EventEmitter { } _playTranscodable(media, options) { + OpusEncoders.guaranteeOpusEngine(); + this.killCurrentTranscoder(); const transcoder = this.prism.transcode({ type: 'ffmpeg', @@ -242,6 +201,8 @@ class VoiceBroadcast extends EventEmitter { * @returns {VoiceBroadcast} */ playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + OpusEncoders.guaranteeOpusEngine(); + this.killCurrentTranscoder(); const options = { seek, volume, passes, stream }; this.currentTranscoder = { options }; @@ -249,6 +210,20 @@ class VoiceBroadcast extends EventEmitter { return this; } + /** + * Plays an Opus encoded stream at 48KHz. + * Note that inline volume is not compatible with this method. + * @param {ReadableStream} stream The Opus audio stream to play + * @param {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + */ + playOpusStream(stream, { seek = 0, passes = 1 } = {}) { + const options = { seek, passes, stream }; + this.currentTranscoder = { options, opus: true }; + stream.once('readable', () => this._startPlaying()); + return this; + } + /** * Play an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description) * @param {string} input the arbitrary input @@ -256,8 +231,10 @@ class VoiceBroadcast extends EventEmitter { * @returns {VoiceBroadcast} */ playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; - return this.player.playUnknownStream(input, options); + this.guaranteeOpusEngine(); + + const options = { seek, volume, passes, input }; + return this._playTranscodable(input, options); } /** @@ -284,6 +261,10 @@ class VoiceBroadcast extends EventEmitter { } } + guaranteeOpusEngine() { + if (!this.opusEncoder) throw new Error('Couldn\'t find an Opus engine.'); + } + _startPlaying() { if (this.tickInterval) clearInterval(this.tickInterval); // this.tickInterval = this.client.setInterval(this.tick.bind(this), 20); @@ -301,9 +282,9 @@ class VoiceBroadcast extends EventEmitter { setTimeout(() => this.tick(), 20); return; } - const stream = this._playableStream; - const bufferLength = 1920 * 2; - let buffer = stream.read(bufferLength); + + const opus = this.currentTranscoder.opus; + const buffer = this.readStreamBuffer(); if (!buffer) { this._missed++; @@ -318,12 +299,6 @@ class VoiceBroadcast extends EventEmitter { this._missed = 0; - if (buffer.length !== bufferLength) { - const newBuffer = Buffer.alloc(bufferLength).fill(0); - buffer.copy(newBuffer); - buffer = newBuffer; - } - let packetMatrix = {}; const getOpusPacket = (volume) => { @@ -336,10 +311,13 @@ class VoiceBroadcast extends EventEmitter { }; for (const dispatcher of this.dispatchers) { + if (opus) { + dispatcher.processPacket(buffer); + continue; + } + const volume = dispatcher.volume; - setImmediate(() => { - dispatcher.process(buffer, true, getOpusPacket(volume)); - }); + dispatcher.processPacket(getOpusPacket(volume)); } const next = 20 + (this._startTime + this._pausedTime + (this._count * 20) - Date.now()); @@ -347,6 +325,22 @@ class VoiceBroadcast extends EventEmitter { setTimeout(() => this.tick(), next); } + readStreamBuffer() { + const opus = this.currentTranscoder.opus; + const bufferLength = (opus ? 80 : 1920) * 2; + let buffer = this._playableStream.read(bufferLength); + if (opus) return buffer; + if (!buffer) return null; + + if (buffer.length !== bufferLength) { + const newBuffer = Buffer.alloc(bufferLength).fill(0); + buffer.copy(newBuffer); + buffer = newBuffer; + } + + return buffer; + } + /** * Stop the current stream from playing without unsubscribing dispatchers. */ diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index f90fec911..771312a81 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -142,6 +142,7 @@ class VoiceConnection extends EventEmitter { self_deaf: false, }, }); + this.player.destroy(); /** * Emitted when the voice connection disconnects * @event VoiceConnection#disconnect @@ -236,8 +237,7 @@ class VoiceConnection extends EventEmitter { * }) * .catch(console.error); */ - playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; + playFile(file, options) { return this.player.playUnknownStream(`file:${file}`, options); } @@ -247,8 +247,7 @@ class VoiceConnection extends EventEmitter { * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} */ - playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; + playArbitraryInput(input, options) { return this.player.playUnknownStream(input, options); } @@ -268,22 +267,31 @@ class VoiceConnection extends EventEmitter { * }) * .catch(console.error); */ - playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; + playStream(stream, options) { return this.player.playUnknownStream(stream, options); } /** * Plays a stream of 16-bit signed stereo PCM at 48KHz. - * @param {ReadableStream} stream The audio stream to play. + * @param {ReadableStream} stream The audio stream to play * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} */ - playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; + playConvertedStream(stream, options) { return this.player.playPCMStream(stream, options); } + /** + * Plays an Opus encoded stream at 48KHz. + * Note that inline volume is not compatible with this method. + * @param {ReadableStream} stream The Opus audio stream to play + * @param {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + */ + playOpusStream(stream, options) { + return this.player.playOpusStream(stream, options); + } + /** * Plays a voice broadcast * @param {VoiceBroadcast} broadcast the broadcast to play diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 0fa0f2368..71af21bb0 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -1,4 +1,4 @@ -const EventEmitter = require('events').EventEmitter; +const VolumeInterface = require('../util/VolumeInterface'); const NaCl = require('tweetnacl'); const VoiceBroadcast = require('../VoiceBroadcast'); @@ -16,9 +16,9 @@ nonce.fill(0); * ``` * @extends {EventEmitter} */ -class StreamDispatcher extends EventEmitter { +class StreamDispatcher extends VolumeInterface { constructor(player, stream, streamOptions) { - super(); + super(streamOptions); /** * The Audio Player that controls this dispatcher * @type {AudioPlayer} @@ -31,7 +31,6 @@ class StreamDispatcher extends EventEmitter { this.stream = stream; if (!(this.stream instanceof VoiceBroadcast)) this.startStreaming(); this.streamOptions = streamOptions; - this.streamOptions.volume = this.streamOptions.volume || 0; const data = this.streamingData; data.length = 20; @@ -48,7 +47,7 @@ class StreamDispatcher extends EventEmitter { */ this.destroyed = false; - this.setVolume(streamOptions.volume || 1); + this._opus = streamOptions.opus; } /** @@ -86,46 +85,6 @@ class StreamDispatcher extends EventEmitter { return this.time + this.streamingData.pausedTime; } - /** - * The volume of the stream, relative to the stream's input volume - * @type {number} - * @readonly - */ - get volume() { - return this.streamOptions.volume; - } - - /** - * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. - * @param {number} volume The volume that you want to set - */ - setVolume(volume) { - /** - * Emitted when the volume of this dispatcher changes - * @param {number} oldVolume the old volume - * @param {number} newVolume the new volume - * @event StreamDispatcher#volumeChange - */ - this.emit('volumeChange', this.streamOptions.volume, volume); - this.streamOptions.volume = volume; - } - - /** - * Set the volume in decibels - * @param {number} db The decibels - */ - setVolumeDecibels(db) { - this.setVolume(Math.pow(10, db / 20)); - } - - /** - * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. - * @param {number} value The value for the volume - */ - setVolumeLogarithmic(value) { - this.setVolume(Math.pow(value, 1.660964)); - } - /** * Stops sending voice packets to the voice connection (stream may still progress however) */ @@ -196,20 +155,7 @@ class StreamDispatcher extends EventEmitter { return packetBuffer; } - applyVolume(buffer) { - if (this.volume === 1) return buffer; - - const out = Buffer.alloc(buffer.length); - for (let i = 0; i < buffer.length; i += 2) { - if (i >= buffer.length - 1) break; - const uint = Math.min(32767, Math.max(-32767, Math.floor(this.volume * buffer.readInt16LE(i)))); - out.writeInt16LE(uint, i); - } - - return out; - } - - process(buffer, controlled, packet) { + processPacket(packet) { try { if (this.destroyed) { this.setSpeaking(false); @@ -218,7 +164,38 @@ class StreamDispatcher extends EventEmitter { const data = this.streamingData; - if (data.missed >= 5 && !controlled) { + if (this.paused) { + this.setSpeaking(false); + data.pausedTime = data.length * 10; + return; + } + + if (!packet) { + data.missed++; + data.pausedTime += data.length * 10; + return; + } + + this.started(); + this.missed = 0; + + this.stepStreamingData(); + this.sendBuffer(null, data.sequence, data.timestamp, packet); + } catch (e) { + this.destroy('error', e); + } + } + + process() { + try { + if (this.destroyed) { + this.setSpeaking(false); + return; + } + + const data = this.streamingData; + + if (data.missed >= 5) { this.destroy('end', 'Stream is not generating quickly enough.'); return; } @@ -227,61 +204,30 @@ class StreamDispatcher extends EventEmitter { this.setSpeaking(false); // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; data.pausedTime += data.length * 10; - // if buffer is provided we are assuming a master process is controlling the dispatcher - if (!buffer) this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); return; } - if (!buffer && controlled) { + this.started(); + + const buffer = this.readStreamBuffer(); + if (!buffer) { data.missed++; data.pausedTime += data.length * 10; + this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); return; } - if (!data.startTime) { - /** - * Emitted once the dispatcher starts streaming - * @event StreamDispatcher#start - */ - this.emit('start'); - data.startTime = Date.now(); - } - - if (packet) { - data.count++; - data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0; - data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - this.sendBuffer(null, data.sequence, data.timestamp, packet); - return; - } - - const bufferLength = 1920 * data.channels; - if (!controlled) { - buffer = this.stream.read(bufferLength); - if (!buffer) { - data.missed++; - data.pausedTime += data.length * 10; - this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10); - return; - } - } - data.missed = 0; - if (buffer.length !== bufferLength) { - const newBuffer = Buffer.alloc(bufferLength).fill(0); - buffer.copy(newBuffer); - buffer = newBuffer; + this.stepStreamingData(); + + if (this._opus) { + this.sendBuffer(null, data.sequence, data.timestamp, buffer); + } else { + this.sendBuffer(buffer, data.sequence, data.timestamp); } - buffer = this.applyVolume(buffer); - - data.count++; - data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0; - data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; - this.sendBuffer(buffer, data.sequence, data.timestamp); - - if (controlled) return; const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now()); this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), nextTime); } catch (e) { @@ -289,6 +235,43 @@ class StreamDispatcher extends EventEmitter { } } + readStreamBuffer() { + const data = this.streamingData; + const bufferLength = (this._opus ? 80 : 1920) * data.channels; + let buffer = this.stream.read(bufferLength); + if (this._opus) return buffer; + if (!buffer) return null; + + if (buffer.length !== bufferLength) { + const newBuffer = Buffer.alloc(bufferLength).fill(0); + buffer.copy(newBuffer); + buffer = newBuffer; + } + + buffer = this.applyVolume(buffer); + return buffer; + } + + started() { + const data = this.streamingData; + + if (!data.startTime) { + /** + * Emitted once the dispatcher starts streaming + * @event StreamDispatcher#start + */ + this.emit('start'); + data.startTime = Date.now(); + } + } + + stepStreamingData() { + const data = this.streamingData; + data.count++; + data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0; + data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0; + } + destroy(type, reason) { if (this.destroyed) return; this.destroyed = true; diff --git a/src/client/voice/opus/BaseOpusEngine.js b/src/client/voice/opus/BaseOpusEngine.js index 6c3ba6e34..47c88c7c6 100644 --- a/src/client/voice/opus/BaseOpusEngine.js +++ b/src/client/voice/opus/BaseOpusEngine.js @@ -10,6 +10,10 @@ class BaseOpus { decode(buffer) { return buffer; } + + destroy() { + return; + } } module.exports = BaseOpus; diff --git a/src/client/voice/opus/OpusEngineList.js b/src/client/voice/opus/OpusEngineList.js index ffd512a64..bc9fe6102 100644 --- a/src/client/voice/opus/OpusEngineList.js +++ b/src/client/voice/opus/OpusEngineList.js @@ -20,5 +20,9 @@ exports.fetch = () => { const fetched = fetch(encoder); if (fetched) return fetched; } - throw new Error('Couldn\'t find an Opus engine.'); + return null; +}; + +exports.guaranteeOpusEngine = () => { + if (!this.opusEncoder) throw new Error('Couldn\'t find an Opus engine.'); }; diff --git a/src/client/voice/opus/OpusScriptEngine.js b/src/client/voice/opus/OpusScriptEngine.js index 33b4ff5a6..c902e790c 100644 --- a/src/client/voice/opus/OpusScriptEngine.js +++ b/src/client/voice/opus/OpusScriptEngine.js @@ -2,7 +2,7 @@ const OpusEngine = require('./BaseOpusEngine'); let OpusScript; -class NodeOpusEngine extends OpusEngine { +class OpusScriptEngine extends OpusEngine { constructor(player) { super(player); try { @@ -22,6 +22,11 @@ class NodeOpusEngine extends OpusEngine { super.decode(buffer); return this.encoder.decode(buffer); } + + destroy() { + super.destroy(); + this.encoder.delete(); + } } -module.exports = NodeOpusEngine; +module.exports = OpusScriptEngine; diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index f67969f95..585af040a 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -50,6 +50,10 @@ class AudioPlayer extends EventEmitter { return this.streams.last().transcoder; } + destroy() { + this.opusEncoder.destroy(); + } + destroyStream(stream) { const data = this.streams.get(stream); if (!data) return; @@ -69,6 +73,7 @@ class AudioPlayer extends EventEmitter { } playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + OpusEncoders.guaranteeOpusEngine(); const options = { seek, volume, passes }; const transcoder = this.prism.transcode({ type: 'ffmpeg', @@ -85,28 +90,39 @@ class AudioPlayer extends EventEmitter { } playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + OpusEncoders.guaranteeOpusEngine(); const options = { seek, volume, passes }; this.destroyAllStreams(stream); - const dispatcher = new StreamDispatcher(this, stream, options); - dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + const dispatcher = this.createDispatcher(stream, options); if (!this.streams.has(stream)) this.streams.set(stream, { dispatcher, input: stream }); this.streams.get(stream).dispatcher = dispatcher; - dispatcher.on('end', () => this.destroyStream(stream)); - dispatcher.on('error', () => this.destroyStream(stream)); + return dispatcher; + } + + playOpusStream(stream, { seek = 0, passes = 1 } = {}) { + const options = { seek, passes, opus: true }; + this.destroyAllStreams(stream); + const dispatcher = this.createDispatcher(stream, options); + this.streams.set(stream, { dispatcher, input: stream }); return dispatcher; } playBroadcast(broadcast, { volume = 1, passes = 1 } = {}) { const options = { volume, passes }; this.destroyAllStreams(); - const dispatcher = new StreamDispatcher(this, broadcast, options); - dispatcher.on('end', () => this.destroyStream(broadcast)); - dispatcher.on('error', () => this.destroyStream(broadcast)); - dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + const dispatcher = this.createDispatcher(broadcast, options); this.streams.set(broadcast, { dispatcher, input: broadcast }); broadcast.registerDispatcher(dispatcher); return dispatcher; } + + createDispatcher(stream, options) { + const dispatcher = new StreamDispatcher(this, stream, options); + dispatcher.on('end', () => this.destroyStream(stream)); + dispatcher.on('error', () => this.destroyStream(stream)); + dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value)); + return dispatcher; + } } module.exports = AudioPlayer; diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 5aec6d35b..0f44d0850 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -84,7 +84,8 @@ class VoiceReceiver extends EventEmitter { stream._push(null); this.opusStreams.delete(id); } - for (const [id] of this.opusEncoders) { + for (const [id, encoder] of this.opusEncoders) { + encoder.destroy(); this.opusEncoders.delete(id); } this.destroyed = true; diff --git a/src/client/voice/util/VolumeInterface.js b/src/client/voice/util/VolumeInterface.js new file mode 100644 index 000000000..40b7bb339 --- /dev/null +++ b/src/client/voice/util/VolumeInterface.js @@ -0,0 +1,57 @@ +const EventEmitter = require('events'); + +class VolumeInterface extends EventEmitter { + constructor({ volume = 0 } = {}) { + super(); + this.setVolume(volume || 1); + } + + applyVolume(buffer, volume) { + volume = volume || this._volume; + if (volume === 1) return buffer; + + const out = new Buffer(buffer.length); + for (let i = 0; i < buffer.length; i += 2) { + if (i >= buffer.length - 1) break; + const uint = Math.min(32767, Math.max(-32767, Math.floor(volume * buffer.readInt16LE(i)))); + out.writeInt16LE(uint, i); + } + + return out; + } + + /** + * Sets the volume relative to the input stream - i.e. 1 is normal, 0.5 is half, 2 is double. + * @param {number} volume The volume that you want to set + */ + setVolume(volume) { + this._volume = volume; + } + + /** + * Set the volume in decibels + * @param {number} db The decibels + */ + setVolumeDecibels(db) { + this.setVolume(Math.pow(10, db / 20)); + } + + /** + * Set the volume so that a perceived value of 0.5 is half the perceived volume etc. + * @param {number} value The value for the volume + */ + setVolumeLogarithmic(value) { + this.setVolume(Math.pow(value, 1.660964)); + } + + /** + * The current volume of the broadcast + * @readonly + * @type {number} + */ + get volume() { + return this._volume; + } +} + +module.exports = VolumeInterface; From 78dafb9480e9c1d8dcc505c89d928c90a07bce0b Mon Sep 17 00:00:00 2001 From: Programmix Date: Mon, 30 Jan 2017 13:38:47 -0800 Subject: [PATCH 46/53] Properly check whether an Opus engine exists (#1150) * Properly check whether an Opus engine exists I think I'm retarded * Fix eslint error * Update OpusEngineList.js --- src/client/voice/opus/OpusEngineList.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client/voice/opus/OpusEngineList.js b/src/client/voice/opus/OpusEngineList.js index bc9fe6102..2aa7f17fc 100644 --- a/src/client/voice/opus/OpusEngineList.js +++ b/src/client/voice/opus/OpusEngineList.js @@ -3,6 +3,8 @@ const list = [ require('./OpusScriptEngine'), ]; +let opusEngineFound; + function fetch(Encoder) { try { return new Encoder(); @@ -24,5 +26,6 @@ exports.fetch = () => { }; exports.guaranteeOpusEngine = () => { - if (!this.opusEncoder) throw new Error('Couldn\'t find an Opus engine.'); + if (typeof opusEngineFound === 'undefined') opusEngineFound = Boolean(exports.fetch()); + if (!opusEngineFound) throw new Error('Couldn\'t find an Opus engine.'); }; From 49944747ae0834cda55081ba99196f849201cce1 Mon Sep 17 00:00:00 2001 From: Jacob Date: Mon, 6 Feb 2017 01:24:54 -0500 Subject: [PATCH 47/53] Sodium (#1172) * Use Native libsodium when available * add newline * fix typo of exports * add to webpack ignore * Update Secretbox.js --- package.json | 3 +++ src/client/voice/dispatcher/StreamDispatcher.js | 5 +++-- src/client/voice/receiver/VoiceReceiver.js | 4 ++-- src/client/voice/util/Secretbox.js | 13 +++++++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 src/client/voice/util/Secretbox.js diff --git a/package.json b/package.json index b899cb35e..1e89e0f0c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "erlpack": "hammerandchisel/erlpack", "node-opus": "^0.2.0", "opusscript": "^0.0.2", + "sodium": "^2.0.1", "uws": "^0.12.0" }, "devDependencies": { @@ -65,6 +66,7 @@ "opusscript": false, "node-opus": false, "tweet-nacl": false, + "sodium": false, "src/sharding/Shard.js": false, "src/sharding/ShardClientUtil.js": false, "src/sharding/ShardingManager.js": false, @@ -82,6 +84,7 @@ "src/client/voice/receiver/VoiceReadable.js": false, "src/client/voice/receiver/VoiceReceiver.js": false, "src/client/voice/util/SecretKey.js": false, + "src/client/voice/util/Secretbox.js": false, "src/client/voice/ClientVoiceManager.js": false, "src/client/voice/VoiceConnection.js": false, "src/client/voice/VoiceUDPClient.js": false, diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 71af21bb0..9c3b56303 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -1,7 +1,8 @@ const VolumeInterface = require('../util/VolumeInterface'); -const NaCl = require('tweetnacl'); const VoiceBroadcast = require('../VoiceBroadcast'); +const secretbox = require('../util/Secretbox'); + const nonce = Buffer.alloc(24); nonce.fill(0); @@ -149,7 +150,7 @@ class StreamDispatcher extends VolumeInterface { packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc, 8, 4); packetBuffer.copy(nonce, 0, 0, 12); - buffer = NaCl.secretbox(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key); + buffer = secretbox.close(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key); for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i]; return packetBuffer; diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 0f44d0850..71a637bd6 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -1,5 +1,5 @@ const EventEmitter = require('events').EventEmitter; -const NaCl = require('tweetnacl'); +const secretbox = require('../util/Secretbox'); const Readable = require('./VoiceReadable'); const OpusEncoders = require('../opus/OpusEngineList'); @@ -123,7 +123,7 @@ class VoiceReceiver extends EventEmitter { handlePacket(msg, user) { msg.copy(nonce, 0, 0, 12); - let data = NaCl.secretbox.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key); + let data = secretbox.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key); if (!data) { /** * Emitted whenever a voice packet experiences a problem. diff --git a/src/client/voice/util/Secretbox.js b/src/client/voice/util/Secretbox.js new file mode 100644 index 000000000..5aa80b088 --- /dev/null +++ b/src/client/voice/util/Secretbox.js @@ -0,0 +1,13 @@ +try { + const sodium = require('sodium'); + module.exports = { + open: sodium.api.crypto_secretbox_open, + close: sodium.api.crypto_secretbox, + }; +} catch (err) { + const tweetnacl = require('tweetnacl'); + module.exports = { + open: tweetnacl.secretbox.open, + close: tweetnacl.secretbox, + }; +} From 0f6fceebb4c13f7c8aabc09bd2f80627dedb15c7 Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Mon, 6 Feb 2017 01:57:08 -0500 Subject: [PATCH 48/53] Update voice webpack exclusions --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index dc63097c1..b6ee2c7e2 100644 --- a/package.json +++ b/package.json @@ -79,13 +79,13 @@ "src/client/voice/pcm/ConverterEngineList.js": false, "src/client/voice/pcm/FfmpegConverterEngine.js": false, "src/client/voice/player/AudioPlayer.js": false, - "src/client/voice/player/BasePlayer.js": false, - "src/client/voice/player/DefaultPlayer.js": false, "src/client/voice/receiver/VoiceReadable.js": false, "src/client/voice/receiver/VoiceReceiver.js": false, - "src/client/voice/util/SecretKey.js": false, "src/client/voice/util/Secretbox.js": false, + "src/client/voice/util/SecretKey.js": false, + "src/client/voice/util/VolumeInterface.js": false, "src/client/voice/ClientVoiceManager.js": false, + "src/client/voice/VoiceBroadcast.js": false, "src/client/voice/VoiceConnection.js": false, "src/client/voice/VoiceUDPClient.js": false, "src/client/voice/VoiceWebSocket.js": false From a90bd837af62ecdd15311c3e7e95df84825cf62b Mon Sep 17 00:00:00 2001 From: Schuyler Cebulskie Date: Mon, 6 Feb 2017 02:06:01 -0500 Subject: [PATCH 49/53] Add sodium to optional packages list --- README.md | 1 + docs/general/welcome.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 574d1366e..98ce59637 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ For production bots, using node-opus should be considered a necessity, especiall ### Optional packages - [bufferutil](https://www.npmjs.com/package/bufferutil) to greatly speed up the `ws` WebSocket connection (`npm install bufferutil --save`) - [erlpack](https://github.com/hammerandchisel/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install hammerandchisel/erlpack --save`) +- [sodium](https://www.npmjs.com/package/sodium) for faster voice packet encryption/decryption (`npm install sodium --save`) - [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws --save`) **Note:** This package does not handle disconnects entirely correctly, which causes automatic reconnection to Discord to not function. If you use this package, it may be wise to destroy + recreate the client entirely or restart the process upon disconnect. diff --git a/docs/general/welcome.md b/docs/general/welcome.md index fa78d5741..c9fa38ba3 100644 --- a/docs/general/welcome.md +++ b/docs/general/welcome.md @@ -45,6 +45,7 @@ For production bots, using node-opus should be considered a necessity, especiall ### Optional packages - [bufferutil](https://www.npmjs.com/package/bufferutil) to greatly speed up the `ws` WebSocket connection (`npm install bufferutil --save`) - [erlpack](https://github.com/hammerandchisel/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install hammerandchisel/erlpack --save`) +- [sodium](https://www.npmjs.com/package/sodium) for faster voice packet encryption/decryption (`npm install sodium --save`) - [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws --save`) **Note:** This package does not handle disconnects entirely correctly, which causes automatic reconnection to Discord to not function. If you use this package, it may be wise to destroy + recreate the client entirely or restart the process upon disconnect. From eb069d0249baeed486450e68d771b23184e0e6fb Mon Sep 17 00:00:00 2001 From: Programmix Date: Wed, 22 Feb 2017 12:10:19 -0800 Subject: [PATCH 50/53] Added volumeChange event to VoiceInterface (#1207) VoiceBroadcast relies on this event to initialize a new Opus engine --- src/client/voice/util/VolumeInterface.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/client/voice/util/VolumeInterface.js b/src/client/voice/util/VolumeInterface.js index 40b7bb339..b12f1e4bc 100644 --- a/src/client/voice/util/VolumeInterface.js +++ b/src/client/voice/util/VolumeInterface.js @@ -25,6 +25,13 @@ class VolumeInterface extends EventEmitter { * @param {number} volume The volume that you want to set */ setVolume(volume) { + /** + * Emitted when the volume of this interface changes + * @event VolumeInterface#volumeChange + * @param {number} oldVolume The old volume of this interface + * @param {number} newVolume The new volume of this interface + */ + this.emit('volumeChange', this._volume, volume); this._volume = volume; } From 7fd94c29d8b427d8c35f4879242ceb7a3b98faf3 Mon Sep 17 00:00:00 2001 From: Programmix Date: Wed, 22 Feb 2017 12:13:52 -0800 Subject: [PATCH 51/53] VoiceConnection rework (#1183) * VoiceConnection rework - improves codebase - removes concept of pending connections - attempts to fix memory leaks by removing EventEmitter listeners - makes voice connections keep track of its own channel when it is moved by another user - allows voice connections to reconnect when Discord falls back to another voice server or a region change occurs - adds events for some of the aforementioned events * Removed unused code * More clean up / bugfixes * Added typedefs to Status and VoiceStatus constants --- src/client/voice/ClientVoiceManager.js | 216 ++----------- src/client/voice/VoiceConnection.js | 347 ++++++++++++++++----- src/client/voice/VoiceUDPClient.js | 6 +- src/client/voice/VoiceWebSocket.js | 4 +- src/client/voice/player/AudioPlayer.js | 2 +- src/client/voice/receiver/VoiceReceiver.js | 24 ++ src/util/Constants.js | 27 ++ 7 files changed, 350 insertions(+), 276 deletions(-) diff --git a/src/client/voice/ClientVoiceManager.js b/src/client/voice/ClientVoiceManager.js index 1abe30eb4..e0d3879ff 100644 --- a/src/client/voice/ClientVoiceManager.js +++ b/src/client/voice/ClientVoiceManager.js @@ -1,8 +1,5 @@ const Collection = require('../../util/Collection'); -const mergeDefault = require('../../util/MergeDefault'); -const Constants = require('../../util/Constants'); const VoiceConnection = require('./VoiceConnection'); -const EventEmitter = require('events').EventEmitter; /** * Manages all the voice stuff for the Client @@ -22,53 +19,21 @@ class ClientVoiceManager { */ this.connections = new Collection(); - /** - * Pending connection attempts, maps guild ID to VoiceChannel - * @type {Collection} - */ - this.pending = new Collection(); - this.client.on('self.voiceServer', this.onVoiceServer.bind(this)); this.client.on('self.voiceStateUpdate', this.onVoiceStateUpdate.bind(this)); } - onVoiceServer(data) { - if (this.pending.has(data.guild_id)) this.pending.get(data.guild_id).setTokenAndEndpoint(data.token, data.endpoint); + onVoiceServer({ guild_id, token, endpoint }) { + const connection = this.connections.get(guild_id); + if (connection) connection.setTokenAndEndpoint(token, endpoint); } - onVoiceStateUpdate(data) { - if (this.pending.has(data.guild_id)) this.pending.get(data.guild_id).setSessionID(data.session_id); - } - - /** - * Sends a request to the main gateway to join a voice channel - * @param {VoiceChannel} channel The channel to join - * @param {Object} [options] The options to provide - */ - sendVoiceStateUpdate(channel, options = {}) { - if (!this.client.user) throw new Error('Unable to join because there is no client user.'); - if (!channel.permissionsFor) { - throw new Error('Channel does not support permissionsFor; is it really a voice channel?'); + onVoiceStateUpdate({ guild_id, session_id, channel_id }) { + const connection = this.connections.get(guild_id); + if (connection) { + connection.channel = this.client.channels.get(channel_id); + connection.setSessionID(session_id); } - const permissions = channel.permissionsFor(this.client.user); - if (!permissions) { - throw new Error('There is no permission set for the client user in this channel - are they part of the guild?'); - } - if (!permissions.hasPermission('CONNECT')) { - throw new Error('You do not have permission to join this voice channel.'); - } - - options = mergeDefault({ - guild_id: channel.guild.id, - channel_id: channel.id, - self_mute: false, - self_deaf: false, - }, options); - - this.client.ws.send({ - op: Constants.OPCodes.VOICE_STATE_UPDATE, - d: options, - }); } /** @@ -78,7 +43,6 @@ class ClientVoiceManager { */ joinChannel(channel) { return new Promise((resolve, reject) => { - if (this.pending.get(channel.guild.id)) throw new Error('Already connecting to this guild\'s voice server.'); if (!channel.joinable) { if (channel.full) { throw new Error('You do not have permission to join this voice channel; it is full.'); @@ -87,165 +51,31 @@ class ClientVoiceManager { } } - const existingConnection = this.connections.get(channel.guild.id); - if (existingConnection) { - if (existingConnection.channel.id !== channel.id) { - this.sendVoiceStateUpdate(channel); - this.connections.get(channel.guild.id).channel = channel; + let connection = this.connections.get(channel.guild.id); + + if (connection) { + if (connection.channel.id !== channel.id) { + this.connections.get(channel.guild.id).updateChannel(channel); } - resolve(existingConnection); + resolve(connection); return; + } else { + connection = new VoiceConnection(this, channel); + this.connections.set(channel.guild.id, connection); } - const pendingConnection = new PendingVoiceConnection(this, channel); - this.pending.set(channel.guild.id, pendingConnection); - - pendingConnection.on('fail', reason => { - this.pending.delete(channel.guild.id); + connection.once('failed', reason => { + this.connections.delete(channel.guild.id); reject(reason); }); - pendingConnection.on('pass', voiceConnection => { - this.pending.delete(channel.guild.id); - this.connections.set(channel.guild.id, voiceConnection); - voiceConnection.once('ready', () => resolve(voiceConnection)); - voiceConnection.once('error', reject); - voiceConnection.once('disconnect', () => this.connections.delete(channel.guild.id)); + connection.once('authenticated', () => { + connection.once('ready', () => resolve(connection)); + connection.once('error', reject); + connection.once('disconnect', () => this.connections.delete(channel.guild.id)); }); }); } } -/** - * Represents a Pending Voice Connection - * @private - */ -class PendingVoiceConnection extends EventEmitter { - constructor(voiceManager, channel) { - super(); - - /** - * The ClientVoiceManager that instantiated this pending connection - * @type {ClientVoiceManager} - */ - this.voiceManager = voiceManager; - - /** - * The channel that this pending voice connection will attempt to join - * @type {VoiceChannel} - */ - this.channel = channel; - - /** - * The timeout that will be invoked after 15 seconds signifying a failure to connect - * @type {Timeout} - */ - this.deathTimer = this.voiceManager.client.setTimeout( - () => this.fail(new Error('Connection not established within 15 seconds.')), 15000); - - /** - * An object containing data required to connect to the voice servers with - * @type {Object} - */ - this.data = {}; - - this.sendVoiceStateUpdate(); - } - - checkReady() { - if (this.data.token && this.data.endpoint && this.data.session_id) { - this.pass(); - return true; - } else { - return false; - } - } - - /** - * Set the token and endpoint required to connect to the the voice servers - * @param {string} token the token - * @param {string} endpoint the endpoint - * @returns {void} - */ - setTokenAndEndpoint(token, endpoint) { - if (!token) { - this.fail(new Error('Token not provided from voice server packet.')); - return; - } - if (!endpoint) { - this.fail(new Error('Endpoint not provided from voice server packet.')); - return; - } - if (this.data.token) { - this.fail(new Error('There is already a registered token for this connection.')); - return; - } - if (this.data.endpoint) { - this.fail(new Error('There is already a registered endpoint for this connection.')); - return; - } - - endpoint = endpoint.match(/([^:]*)/)[0]; - - if (!endpoint) { - this.fail(new Error('Failed to find an endpoint.')); - return; - } - - this.data.token = token; - this.data.endpoint = endpoint; - - this.checkReady(); - } - - /** - * Sets the Session ID for the connection - * @param {string} sessionID the session ID - */ - setSessionID(sessionID) { - if (!sessionID) { - this.fail(new Error('Session ID not supplied.')); - return; - } - if (this.data.session_id) { - this.fail(new Error('There is already a registered session ID for this connection.')); - return; - } - this.data.session_id = sessionID; - - this.checkReady(); - } - - clean() { - clearInterval(this.deathTimer); - this.emit('fail', new Error('Clean-up triggered :fourTriggered:')); - } - - pass() { - clearInterval(this.deathTimer); - this.emit('pass', this.upgrade()); - } - - fail(reason) { - this.emit('fail', reason); - this.clean(); - } - - sendVoiceStateUpdate() { - try { - this.voiceManager.sendVoiceStateUpdate(this.channel); - } catch (error) { - this.fail(error); - } - } - - /** - * Upgrades this Pending Connection to a full Voice Connection - * @returns {VoiceConnection} - */ - upgrade() { - return new VoiceConnection(this); - } -} - module.exports = ClientVoiceManager; diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 771312a81..ceb34c427 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -1,5 +1,6 @@ const VoiceWebSocket = require('./VoiceWebSocket'); const VoiceUDP = require('./VoiceUDPClient'); +const mergeDefault = require('../../util/MergeDefault'); const Constants = require('../../util/Constants'); const AudioPlayer = require('./player/AudioPlayer'); const VoiceReceiver = require('./receiver/VoiceReceiver'); @@ -17,14 +18,20 @@ const Prism = require('prism-media'); * @extends {EventEmitter} */ class VoiceConnection extends EventEmitter { - constructor(pendingConnection) { + constructor(voiceManager, channel) { super(); /** - * The Voice Manager that instantiated this connection + * The voice manager that instantiated this connection * @type {ClientVoiceManager} */ - this.voiceManager = pendingConnection.voiceManager; + this.voiceManager = voiceManager; + + /** + * The client that instantiated this connection + * @type {Client} + */ + this.client = voiceManager.client; /** * @external Prism @@ -41,7 +48,13 @@ class VoiceConnection extends EventEmitter { * The voice channel this connection is currently serving * @type {VoiceChannel} */ - this.channel = pendingConnection.channel; + this.channel = channel; + + /** + * The current status of the voice connection + * @type {number} + */ + this.status = Constants.VoiceStatus.AUTHENTICATING; /** * Whether we're currently transmitting audio @@ -60,7 +73,7 @@ class VoiceConnection extends EventEmitter { * @type {Object} * @private */ - this.authentication = pendingConnection.data; + this.authentication = {}; /** * The audio player for this voice connection @@ -93,20 +106,14 @@ class VoiceConnection extends EventEmitter { */ this.ssrcMap = new Map(); - /** - * Whether this connection is ready - * @type {boolean} - * @private - */ - this.ready = false; - /** * Object that wraps contains the `ws` and `udp` sockets of this voice connection * @type {Object} * @private */ this.sockets = {}; - this.connect(); + + this.authenticate(); } /** @@ -128,21 +135,169 @@ class VoiceConnection extends EventEmitter { }); } + /** + * Sends a request to the main gateway to join a voice channel + * @param {Object} [options] The options to provide + */ + sendVoiceStateUpdate(options = {}) { + options = mergeDefault({ + guild_id: this.channel.guild.id, + channel_id: this.channel.id, + self_mute: false, + self_deaf: false, + }, options); + + this.client.ws.send({ + op: Constants.OPCodes.VOICE_STATE_UPDATE, + d: options, + }); + } + + /** + * Set the token and endpoint required to connect to the the voice servers + * @param {string} token The voice token + * @param {string} endpoint The voice endpoint + * @returns {void} + */ + setTokenAndEndpoint(token, endpoint) { + if (!token) { + this.authenticateFailed('Token not provided from voice server packet.'); + return; + } + if (!endpoint) { + this.authenticateFailed('Endpoint not provided from voice server packet.'); + return; + } + + endpoint = endpoint.match(/([^:]*)/)[0]; + + if (!endpoint) { + this.authenticateFailed('Failed to find an endpoint.'); + return; + } + + if (this.status === Constants.VoiceStatus.AUTHENTICATING) { + this.authentication.token = token; + this.authentication.endpoint = endpoint; + this.checkAuthenticated(); + } else if (token !== this.authentication.token || endpoint !== this.authentication.endpoint) { + this.reconnect(token, endpoint); + } + } + + /** + * Sets the Session ID for the connection + * @param {string} sessionID The voice session ID + */ + setSessionID(sessionID) { + if (!sessionID) { + this.authenticateFailed('Session ID not supplied.'); + return; + } + + if (this.status === Constants.VoiceStatus.AUTHENTICATING) { + this.authentication.sessionID = sessionID; + this.checkAuthenticated(); + } else if (sessionID !== this.authentication.sessionID) { + this.authentication.sessionID = sessionID; + /** + * Emitted when a new session ID is received + * @event VoiceConnection#newSession + * @private + */ + this.emit('newSession', sessionID); + } + } + + /** + * Checks whether the voice connection is authenticated + * @private + */ + checkAuthenticated() { + const { token, endpoint, sessionID } = this.authentication; + + if (token && endpoint && sessionID) { + clearTimeout(this.connectTimeout); + this.status = Constants.VoiceStatus.CONNECTING; + /** + * Emitted when we successfully initiate a voice connection + * @event VoiceConnection#authenticated + */ + this.emit('authenticated'); + this.connect(); + } + } + + /** + * Invoked when we fail to initiate a voice connection + * @param {string} reason The reason for failure + * @private + */ + authenticateFailed(reason) { + clearTimeout(this.connectTimeout); + this.status = Constants.VoiceStatus.DISCONNECTED; + if (this.status === Constants.VoiceStatus.AUTHENTICATING) { + /** + * Emitted when we fail to initiate a voice connection + * @event VoiceConnection#failed + * @param {Error} error The encountered error + */ + this.emit('failed', new Error(reason)); + } else { + this.emit('error', new Error(reason)); + } + } + + /** + * Move to a different voice channel in the same guild + * @param {VoiceChannel} channel The channel to move to + * @private + */ + updateChannel(channel) { + this.channel = channel; + this.sendVoiceStateUpdate(); + } + + /** + * Attempts to authenticate to the voice server + * @private + */ + authenticate() { + this.sendVoiceStateUpdate(); + this.connectTimeout = this.client.setTimeout( + () => this.authenticateFailed(new Error('Connection not established within 15 seconds.')), 15000); + } + + /** + * Attempts to reconnect to the voice server (typically after a region change) + * @param {string} token The voice token + * @param {string} endpoint The voice endpoint + * @private + */ + reconnect(token, endpoint) { + this.authentication.token = token; + this.authentication.endpoint = endpoint; + + this.status = Constants.VoiceStatus.RECONNECTING; + /** + * Emitted when the voice connection is reconnecting (typically after a region change) + * @event VoiceConnection#reconnecting + */ + this.emit('reconnecting'); + this.connect(); + } + /** * Disconnect the voice connection, causing a disconnect and closing event to be emitted. */ disconnect() { this.emit('closing'); - this.voiceManager.client.ws.send({ - op: Constants.OPCodes.VOICE_STATE_UPDATE, - d: { - guild_id: this.channel.guild.id, - channel_id: null, - self_mute: false, - self_deaf: false, - }, + this.sendVoiceStateUpdate({ + channel_id: null, }); this.player.destroy(); + this.cleanup(); + this.status = Constants.VoiceStatus.DISCONNECTED; /** * Emitted when the voice connection disconnects * @event VoiceConnection#disconnect @@ -150,70 +305,108 @@ class VoiceConnection extends EventEmitter { this.emit('disconnect'); } + /** + * Cleans up after disconnect + * @private + */ + cleanup() { + const { ws, udp } = this.sockets; + ws.removeAllListeners('error'); + udp.removeAllListeners('error'); + ws.removeAllListeners('ready'); + ws.removeAllListeners('sessionDescription'); + ws.removeAllListeners('speaking'); + this.sockets.ws = null; + this.sockets.udp = null; + } + /** * Connect the voice connection * @private */ connect() { - if (this.sockets.ws) throw new Error('There is already an existing WebSocket connection.'); - if (this.sockets.udp) throw new Error('There is already an existing UDP connection.'); + if (this.status !== Constants.VoiceStatus.RECONNECTING) { + if (this.sockets.ws) throw new Error('There is already an existing WebSocket connection.'); + if (this.sockets.udp) throw new Error('There is already an existing UDP connection.'); + } + + if (this.sockets.ws) this.sockets.ws.shutdown(); + if (this.sockets.udp) this.sockets.udp.shutdown(); + this.sockets.ws = new VoiceWebSocket(this); this.sockets.udp = new VoiceUDP(this); - this.sockets.ws.on('error', e => this.emit('error', e)); - this.sockets.udp.on('error', e => this.emit('error', e)); - this.sockets.ws.on('ready', d => { - this.authentication.port = d.port; - this.authentication.ssrc = d.ssrc; - /** - * Emitted whenever the connection encounters an error. - * @event VoiceConnection#error - * @param {Error} error the encountered error - */ - this.sockets.udp.findEndpointAddress() - .then(address => { - this.sockets.udp.createUDPSocket(address); - }, e => this.emit('error', e)); - }); - this.sockets.ws.on('sessionDescription', (mode, secret) => { - this.authentication.encryptionMode = mode; - this.authentication.secretKey = secret; - /** - * Emitted once the connection is ready, when a promise to join a voice channel resolves, - * the connection will already be ready. - * @event VoiceConnection#ready - */ - this.emit('ready'); - this.ready = true; - }); - this.sockets.ws.on('speaking', data => { - const guild = this.channel.guild; - const user = this.voiceManager.client.users.get(data.user_id); - this.ssrcMap.set(+data.ssrc, user); - if (!data.speaking) { - for (const receiver of this.receivers) { - const opusStream = receiver.opusStreams.get(user.id); - const pcmStream = receiver.pcmStreams.get(user.id); - if (opusStream) { - opusStream.push(null); - opusStream.open = false; - receiver.opusStreams.delete(user.id); - } - if (pcmStream) { - pcmStream.push(null); - pcmStream.open = false; - receiver.pcmStreams.delete(user.id); - } - } + + const { ws, udp } = this.sockets; + + ws.on('error', err => this.emit('error', err)); + udp.on('error', err => this.emit('error', err)); + ws.on('ready', this.onReady.bind(this)); + ws.on('sessionDescription', this.onSessionDescription.bind(this)); + ws.on('speaking', this.onSpeaking.bind(this)); + } + + /** + * Invoked when the voice websocket is ready + * @param {Object} data The received data + * @private + */ + onReady({ port, ssrc }) { + this.authentication.port = port; + this.authentication.ssrc = ssrc; + + const udp = this.sockets.udp; + /** + * Emitted whenever the connection encounters an error. + * @event VoiceConnection#error + * @param {Error} error The encountered error + */ + udp.findEndpointAddress() + .then(address => { + udp.createUDPSocket(address); + }, e => this.emit('error', e)); + } + + /** + * Invoked when a session description is received + * @param {string} mode The encryption mode + * @param {string} secret The secret key + * @private + */ + onSessionDescription(mode, secret) { + this.authentication.encryptionMode = mode; + this.authentication.secretKey = secret; + + this.status = Constants.VoiceStatus.CONNECTED; + /** + * Emitted once the connection is ready, when a promise to join a voice channel resolves, + * the connection will already be ready. + * @event VoiceConnection#ready + */ + this.emit('ready'); + } + + /** + * Invoked when a speaking event is received + * @param {Object} data The received data + * @private + */ + onSpeaking({ user_id, ssrc, speaking }) { + const guild = this.channel.guild; + const user = this.client.users.get(user_id); + this.ssrcMap.set(+ssrc, user); + if (!speaking) { + for (const receiver of this.receivers) { + receiver.stoppedSpeaking(user); } - /** - * Emitted whenever a user starts/stops speaking - * @event VoiceConnection#speaking - * @param {User} user The user that has started/stopped speaking - * @param {boolean} speaking Whether or not the user is speaking - */ - if (this.ready) this.emit('speaking', user, data.speaking); - guild._memberSpeakUpdate(data.user_id, data.speaking); - }); + } + /** + * Emitted whenever a user starts/stops speaking + * @event VoiceConnection#speaking + * @param {User} user The user that has started/stopped speaking + * @param {boolean} speaking Whether or not the user is speaking + */ + if (this.status === Constants.Status.CONNECTED) this.emit('speaking', user, speaking); + guild._memberSpeakUpdate(user_id, speaking); } /** diff --git a/src/client/voice/VoiceUDPClient.js b/src/client/voice/VoiceUDPClient.js index 7fc2ca577..c8b32f126 100644 --- a/src/client/voice/VoiceUDPClient.js +++ b/src/client/voice/VoiceUDPClient.js @@ -47,12 +47,12 @@ class VoiceConnectionUDPClient extends EventEmitter { shutdown() { if (this.socket) { + this.socket.removeAllListeners('message'); try { this.socket.close(); - } catch (e) { - return; + } finally { + this.socket = null; } - this.socket = null; } } diff --git a/src/client/voice/VoiceWebSocket.js b/src/client/voice/VoiceWebSocket.js index d0996ff97..4edf11a7e 100644 --- a/src/client/voice/VoiceWebSocket.js +++ b/src/client/voice/VoiceWebSocket.js @@ -66,7 +66,7 @@ class VoiceWebSocket extends EventEmitter { connect() { if (this.dead) return; if (this.ws) this.reset(); - if (this.attempts > 5) { + if (this.attempts >= 5) { this.emit('debug', new Error(`Too many connection attempts (${this.attempts}).`)); return; } @@ -124,7 +124,7 @@ class VoiceWebSocket extends EventEmitter { server_id: this.voiceConnection.channel.guild.id, user_id: this.client.user.id, token: this.voiceConnection.authentication.token, - session_id: this.voiceConnection.authentication.session_id, + session_id: this.voiceConnection.authentication.sessionID, }, }).catch(() => { this.emit('error', new Error('Tried to send join packet, but the WebSocket is not open.')); diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index 585af040a..11b611394 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -51,7 +51,7 @@ class AudioPlayer extends EventEmitter { } destroy() { - this.opusEncoder.destroy(); + if (this.opusEncoder) this.opusEncoder.destroy(); } destroyStream(stream) { diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 71a637bd6..de78322eb 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -91,6 +91,30 @@ class VoiceReceiver extends EventEmitter { this.destroyed = true; } + /** + * Invoked when a user stops speaking + * @param {User} user The user that stopped speaking + * @private + */ + stoppedSpeaking(user) { + const opusStream = this.opusStreams.get(user.id); + const pcmStream = this.pcmStreams.get(user.id); + const opusEncoder = this.opusEncoders.get(user.id); + if (opusStream) { + opusStream.push(null); + opusStream.open = false; + this.opusStreams.delete(user.id); + } + if (pcmStream) { + pcmStream.push(null); + pcmStream.open = false; + this.pcmStreams.delete(user.id); + } + if (opusEncoder) { + opusEncoder.destroy(); + } + } + /** * Creates a readable stream for a user that provides opus data while the user is speaking. When the user * stops speaking, the stream is destroyed. diff --git a/src/util/Constants.js b/src/util/Constants.js index df1c7b227..baea1da2a 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -163,6 +163,16 @@ const Endpoints = exports.Endpoints = { emoji: (emojiID) => `${Endpoints.CDN}/emojis/${emojiID}.png`, }; +/** + * The current status of the client. Here are the available statuses: + * - READY + * - CONNECTING + * - RECONNECTING + * - IDLE + * - NEARLY + * - DISCONNECTED + * @typedef {number} Status + */ exports.Status = { READY: 0, CONNECTING: 1, @@ -172,6 +182,23 @@ exports.Status = { DISCONNECTED: 5, }; +/** + * The current status of a voice connection. Here are the available statuses: + * - CONNECTED + * - CONNECTING + * - AUTHENTICATING + * - RECONNECTING + * - DISCONNECTED + * @typedef {number} VoiceStatus + */ +exports.VoiceStatus = { + CONNECTED: 0, + CONNECTING: 1, + AUTHENTICATING: 2, + RECONNECTING: 3, + DISCONNECTED: 4, +}; + exports.ChannelTypes = { text: 0, DM: 1, From b55e6927e9744b7b9c72e80a5466eb229fd7ea76 Mon Sep 17 00:00:00 2001 From: Amish Shah Date: Wed, 22 Feb 2017 20:39:21 +0000 Subject: [PATCH 52/53] merge fix --- src/client/voice/ClientVoiceManager.js | 14 -------------- typings | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/client/voice/ClientVoiceManager.js b/src/client/voice/ClientVoiceManager.js index a40366079..e0d3879ff 100644 --- a/src/client/voice/ClientVoiceManager.js +++ b/src/client/voice/ClientVoiceManager.js @@ -1,6 +1,4 @@ const Collection = require('../../util/Collection'); -const Constants = require('../../util/Constants'); -const Util = require('../../util/Util'); const VoiceConnection = require('./VoiceConnection'); /** @@ -36,18 +34,6 @@ class ClientVoiceManager { connection.channel = this.client.channels.get(channel_id); connection.setSessionID(session_id); } - - options = Util.mergeDefault({ - guild_id: channel.guild.id, - channel_id: channel.id, - self_mute: false, - self_deaf: false, - }, options); - - this.client.ws.send({ - op: Constants.OPCodes.VOICE_STATE_UPDATE, - d: options, - }); } /** diff --git a/typings b/typings index 997abfd2d..3dbeb51fd 160000 --- a/typings +++ b/typings @@ -1 +1 @@ -Subproject commit 997abfd2d5b3fbc958f4cc11012a5fe41065aee8 +Subproject commit 3dbeb51fd2a0ec0ca87c4ddcf20c1c1498633762 From bf25caf3d346e5e1f07a0b097b717c983dbf8aa0 Mon Sep 17 00:00:00 2001 From: Programmix Date: Wed, 22 Feb 2017 14:10:26 -0800 Subject: [PATCH 53/53] Updated VoiceConnection to use new Util class (#1210) pls @Gawdl3y pls --- src/client/voice/VoiceConnection.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index ceb34c427..e89eaf3d6 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -1,6 +1,6 @@ const VoiceWebSocket = require('./VoiceWebSocket'); const VoiceUDP = require('./VoiceUDPClient'); -const mergeDefault = require('../../util/MergeDefault'); +const Util = require('../../util/Util'); const Constants = require('../../util/Constants'); const AudioPlayer = require('./player/AudioPlayer'); const VoiceReceiver = require('./receiver/VoiceReceiver'); @@ -140,7 +140,7 @@ class VoiceConnection extends EventEmitter { * @param {Object} [options] The options to provide */ sendVoiceStateUpdate(options = {}) { - options = mergeDefault({ + options = Util.mergeDefault({ guild_id: this.channel.guild.id, channel_id: this.channel.id, self_mute: false,