diff options
Diffstat (limited to 'node_modules/discord.js/src/client/voice')
18 files changed, 1825 insertions, 0 deletions
diff --git a/node_modules/discord.js/src/client/voice/ClientVoiceManager.js b/node_modules/discord.js/src/client/voice/ClientVoiceManager.js new file mode 100644 index 0000000..ea83745 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/ClientVoiceManager.js @@ -0,0 +1,245 @@ +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 + * @private + */ +class ClientVoiceManager { + constructor(client) { + /** + * The client that instantiated this voice manager + * @type {Client} + */ + this.client = client; + + /** + * A collection mapping connection IDs to the Connection objects + * @type {Collection<string, VoiceConnection>} + */ + this.connections = new Collection(); + + /** + * Pending connection attempts, maps guild ID to VoiceChannel + * @type {Collection<string, VoiceChannel>} + */ + 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); + } + + 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?'); + } + 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, + }); + } + + /** + * Sets up a request to join a voice channel + * @param {VoiceChannel} channel The voice channel to join + * @returns {Promise<VoiceConnection>} + */ + 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) throw new Error('You do not have permission to join this voice channel.'); + + 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; + } + resolve(existingConnection); + return; + } + + const pendingConnection = new PendingVoiceConnection(this, channel); + this.pending.set(channel.guild.id, pendingConnection); + + pendingConnection.on('fail', reason => { + this.pending.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)); + }); + }); + } +} + +/** + * 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/node_modules/discord.js/src/client/voice/VoiceConnection.js b/node_modules/discord.js/src/client/voice/VoiceConnection.js new file mode 100644 index 0000000..ac44ff8 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/VoiceConnection.js @@ -0,0 +1,276 @@ +const VoiceWebSocket = require('./VoiceWebSocket'); +const VoiceUDP = require('./VoiceUDPClient'); +const Constants = require('../../util/Constants'); +const AudioPlayer = require('./player/AudioPlayer'); +const VoiceReceiver = require('./receiver/VoiceReceiver'); +const EventEmitter = require('events').EventEmitter; +const fs = require('fs'); + +/** + * Represents a connection to a voice channel in Discord. + * ```js + * // obtained using: + * voiceChannel.join().then(connection => { + * + * }); + * ``` + * @extends {EventEmitter} + */ +class VoiceConnection extends EventEmitter { + constructor(pendingConnection) { + super(); + + /** + * The Voice Manager that instantiated this connection + * @type {ClientVoiceManager} + */ + this.voiceManager = pendingConnection.voiceManager; + + /** + * The voice channel this connection is currently serving + * @type {VoiceChannel} + */ + this.channel = pendingConnection.channel; + + /** + * Whether we're currently transmitting audio + * @type {boolean} + */ + this.speaking = false; + + /** + * An array of Voice Receivers that have been created for this connection + * @type {VoiceReceiver[]} + */ + this.receivers = []; + + /** + * The authentication data needed to connect to the voice server + * @type {Object} + * @private + */ + this.authentication = pendingConnection.data; + + /** + * The audio player for this voice connection + * @type {AudioPlayer} + */ + this.player = new AudioPlayer(this); + + this.player.on('debug', m => { + /** + * Debug info from the connection + * @event VoiceConnection#debug + * @param {string} message the debug message + */ + this.emit('debug', `audio player - ${m}`); + }); + + this.player.on('error', e => { + /** + * Warning info from the connection + * @event VoiceConnection#warn + * @param {string|Error} warning the warning + */ + this.emit('warn', e); + this.player.cleanup(); + }); + + /** + * Map SSRC to speaking values + * @type {Map<number, boolean>} + * @private + */ + 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(); + } + + /** + * Sets whether the voice connection should display as "speaking" or not + * @param {boolean} value whether or not to speak + * @private + */ + setSpeaking(value) { + if (this.speaking === value) return; + this.speaking = value; + this.sockets.ws.sendPacket({ + op: Constants.VoiceOPCodes.SPEAKING, + d: { + speaking: true, + delay: 0, + }, + }).catch(e => { + this.emit('debug', e); + }); + } + + /** + * 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, + }, + }); + /** + * Emitted when the voice connection disconnects + * @event VoiceConnection#disconnect + */ + this.emit('disconnect'); + } + + /** + * 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.'); + 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.once('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.once('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); + } + } + } + /** + * 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); + }); + } + + /** + * Options that can be passed to stream-playing methods: + * @typedef {Object} StreamOptions + * @property {number} [seek=0] The time to seek to + * @property {number} [volume=1] The volume to play at + * @property {number} [passes=1] How many times to send the voice packet to reduce packet loss + */ + + /** + * 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 + * voiceChannel.join() + * .then(connection => { + * const dispatcher = connection.playFile('C:/Users/Discord/Desktop/music.mp3'); + * }) + * .catch(console.error); + */ + playFile(file, options) { + return this.playStream(fs.createReadStream(file), options); + } + + /** + * Plays and converts an audio stream in the voice connection. + * @param {ReadableStream} stream The audio stream to play + * @param {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + * @example + * // play streams using ytdl-core + * const ytdl = require('ytdl-core'); + * const streamOptions = { seek: 0, volume: 1 }; + * voiceChannel.join() + * .then(connection => { + * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', {filter : 'audioonly'}); + * const dispatcher = connection.playStream(stream, streamOptions); + * }) + * .catch(console.error); + */ + playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + 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 {StreamOptions} [options] Options for playing the stream + * @returns {StreamDispatcher} + */ + playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + return this.player.playPCMStream(stream, null, options); + } + + /** + * Creates a VoiceReceiver so you can start listening to voice data. It's recommended to only create one of these. + * @returns {VoiceReceiver} + */ + createReceiver() { + const receiver = new VoiceReceiver(this); + this.receivers.push(receiver); + return receiver; + } +} + +module.exports = VoiceConnection; diff --git a/node_modules/discord.js/src/client/voice/VoiceUDPClient.js b/node_modules/discord.js/src/client/voice/VoiceUDPClient.js new file mode 100644 index 0000000..b7b0c0c --- /dev/null +++ b/node_modules/discord.js/src/client/voice/VoiceUDPClient.js @@ -0,0 +1,145 @@ +const udp = require('dgram'); +const dns = require('dns'); +const Constants = require('../../util/Constants'); +const EventEmitter = require('events').EventEmitter; + +/** + * Represents a UDP Client for a Voice Connection + * @extends {EventEmitter} + * @private + */ +class VoiceConnectionUDPClient extends EventEmitter { + constructor(voiceConnection) { + super(); + + /** + * The voice connection that this UDP client serves + * @type {VoiceConnection} + */ + this.voiceConnection = voiceConnection; + + /** + * The UDP socket + * @type {?Socket} + */ + this.socket = null; + + /** + * The address of the discord voice server + * @type {?string} + */ + this.discordAddress = null; + + /** + * The local IP address + * @type {?string} + */ + this.localAddress = null; + + /** + * The local port + * @type {?string} + */ + this.localPort = null; + + this.voiceConnection.on('closing', this.shutdown.bind(this)); + } + + shutdown() { + if (this.socket) { + try { + this.socket.close(); + } catch (e) { + return; + } + this.socket = null; + } + } + + /** + * The port of the discord voice server + * @type {number} + * @readonly + */ + get discordPort() { + return this.voiceConnection.authentication.port; + } + + /** + * Tries to resolve the voice server endpoint to an address + * @returns {Promise<string>} + */ + findEndpointAddress() { + return new Promise((resolve, reject) => { + dns.lookup(this.voiceConnection.authentication.endpoint, (error, address) => { + if (error) { + reject(error); + return; + } + this.discordAddress = address; + resolve(address); + }); + }); + } + + /** + * Send a packet to the UDP client + * @param {Object} packet the packet to send + * @returns {Promise<Object>} + */ + send(packet) { + return new Promise((resolve, reject) => { + if (!this.socket) throw new Error('Tried to send a UDP packet, but there is no socket available.'); + if (!this.discordAddress || !this.discordPort) throw new Error('Malformed UDP address or port.'); + this.socket.send(packet, 0, packet.length, this.discordPort, this.discordAddress, error => { + if (error) reject(error); else resolve(packet); + }); + }); + } + + createUDPSocket(address) { + this.discordAddress = address; + const socket = this.socket = udp.createSocket('udp4'); + + socket.once('message', message => { + const packet = parseLocalPacket(message); + if (packet.error) { + this.emit('error', packet.error); + return; + } + + this.localAddress = packet.address; + this.localPort = packet.port; + + this.voiceConnection.sockets.ws.sendPacket({ + op: Constants.VoiceOPCodes.SELECT_PROTOCOL, + d: { + protocol: 'udp', + data: { + address: packet.address, + port: packet.port, + mode: 'xsalsa20_poly1305', + }, + }, + }); + }); + + const blankMessage = new Buffer(70); + blankMessage.writeUIntBE(this.voiceConnection.authentication.ssrc, 0, 4); + this.send(blankMessage); + } +} + +function parseLocalPacket(message) { + try { + const packet = new Buffer(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); + return { address, port }; + } catch (error) { + return { error }; + } +} + +module.exports = VoiceConnectionUDPClient; diff --git a/node_modules/discord.js/src/client/voice/VoiceWebSocket.js b/node_modules/discord.js/src/client/voice/VoiceWebSocket.js new file mode 100644 index 0000000..bafa5dd --- /dev/null +++ b/node_modules/discord.js/src/client/voice/VoiceWebSocket.js @@ -0,0 +1,249 @@ +const Constants = require('../../util/Constants'); +const SecretKey = require('./util/SecretKey'); +const EventEmitter = require('events').EventEmitter; + +let WebSocket; +try { + WebSocket = require('uws'); +} catch (err) { + WebSocket = require('ws'); +} + +/** + * Represents a Voice Connection's WebSocket + * @extends {EventEmitter} + * @private + */ +class VoiceWebSocket extends EventEmitter { + constructor(voiceConnection) { + super(); + + /** + * The Voice Connection that this WebSocket serves + * @type {VoiceConnection} + */ + this.voiceConnection = voiceConnection; + + /** + * How many connection attempts have been made + * @type {number} + */ + this.attempts = 0; + + this.connect(); + this.dead = false; + this.voiceConnection.on('closing', this.shutdown.bind(this)); + } + + shutdown() { + this.dead = true; + this.reset(); + } + + /** + * The client of this voice websocket + * @type {Client} + * @readonly + */ + get client() { + return this.voiceConnection.voiceManager.client; + } + + /** + * Resets the current WebSocket + */ + reset() { + if (this.ws) { + if (this.ws.readyState !== WebSocket.CLOSED) this.ws.close(); + this.ws = null; + } + this.clearHeartbeat(); + } + + /** + * Starts connecting to the Voice WebSocket Server. + */ + connect() { + if (this.dead) return; + if (this.ws) this.reset(); + if (this.attempts > 5) { + this.emit('error', new Error(`Too many connection attempts (${this.attempts}).`)); + return; + } + + this.attempts++; + + /** + * The actual WebSocket used to connect to the Voice WebSocket Server. + * @type {WebSocket} + */ + this.ws = new WebSocket(`wss://${this.voiceConnection.authentication.endpoint}`); + this.ws.onopen = this.onOpen.bind(this); + this.ws.onmessage = this.onMessage.bind(this); + this.ws.onclose = this.onClose.bind(this); + this.ws.onerror = this.onError.bind(this); + } + + /** + * Sends data to the WebSocket if it is open. + * @param {string} data the data to send to the WebSocket + * @returns {Promise<string>} + */ + send(data) { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error(`Voice websocket not open to send ${data}.`); + } + this.ws.send(data, null, error => { + if (error) reject(error); else resolve(data); + }); + }); + } + + /** + * JSON.stringify's a packet and then sends it to the WebSocket Server. + * @param {Object} packet the packet to send + * @returns {Promise<string>} + */ + sendPacket(packet) { + try { + packet = JSON.stringify(packet); + } catch (error) { + return Promise.reject(error); + } + return this.send(packet); + } + + /** + * Called whenever the WebSocket opens + */ + onOpen() { + this.sendPacket({ + op: Constants.OPCodes.DISPATCH, + d: { + 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, + }, + }).catch(() => { + this.emit('error', new Error('Tried to send join packet, but the WebSocket is not open.')); + }); + } + + /** + * Called whenever a message is received from the WebSocket + * @param {MessageEvent} event the message event that was received + * @returns {void} + */ + onMessage(event) { + try { + return this.onPacket(JSON.parse(event.data)); + } catch (error) { + return this.onError(error); + } + } + + /** + * Called whenever the connection to the WebSocket Server is lost + */ + onClose() { + if (!this.dead) this.client.setTimeout(this.connect.bind(this), this.attempts * 1000); + } + + /** + * Called whenever an error occurs with the WebSocket. + * @param {Error} error the error that occurred + */ + onError(error) { + this.emit('error', error); + } + + /** + * Called whenever a valid packet is received from the WebSocket + * @param {Object} packet the received packet + */ + onPacket(packet) { + switch (packet.op) { + case Constants.VoiceOPCodes.READY: + this.setHeartbeat(packet.d.heartbeat_interval); + /** + * Emitted once the voice websocket receives the ready packet + * @param {Object} packet the received packet + * @event VoiceWebSocket#ready + */ + this.emit('ready', packet.d); + break; + case Constants.VoiceOPCodes.SESSION_DESCRIPTION: + /** + * Emitted once the Voice Websocket receives a description of this voice session + * @param {string} encryptionMode the type of encryption being used + * @param {SecretKey} secretKey the secret key used for encryption + * @event VoiceWebSocket#sessionDescription + */ + this.emit('sessionDescription', packet.d.mode, new SecretKey(packet.d.secret_key)); + break; + case Constants.VoiceOPCodes.SPEAKING: + /** + * Emitted whenever a speaking packet is received + * @param {Object} data + * @event VoiceWebSocket#speaking + */ + this.emit('speaking', packet.d); + break; + default: + /** + * Emitted when an unhandled packet is received + * @param {Object} packet + * @event VoiceWebSocket#unknownPacket + */ + this.emit('unknownPacket', packet); + break; + } + } + + /** + * Sets an interval at which to send a heartbeat packet to the WebSocket + * @param {number} interval the interval at which to send a heartbeat packet + */ + setHeartbeat(interval) { + if (!interval || isNaN(interval)) { + this.onError(new Error('Tried to set voice heartbeat but no valid interval was specified.')); + return; + } + if (this.heartbeatInterval) { + /** + * Emitted whenver the voice websocket encounters a non-fatal error + * @param {string} warn the warning + * @event VoiceWebSocket#warn + */ + this.emit('warn', 'A voice heartbeat interval is being overwritten'); + clearInterval(this.heartbeatInterval); + } + this.heartbeatInterval = this.client.setInterval(this.sendHeartbeat.bind(this), interval); + } + + /** + * Clears a heartbeat interval, if one exists + */ + clearHeartbeat() { + if (!this.heartbeatInterval) { + this.emit('warn', 'Tried to clear a heartbeat interval that does not exist'); + return; + } + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + + /** + * Sends a heartbeat packet + */ + sendHeartbeat() { + this.sendPacket({ op: Constants.VoiceOPCodes.HEARTBEAT, d: null }).catch(() => { + this.emit('warn', 'Tried to send heartbeat, but connection is not open'); + this.clearHeartbeat(); + }); + } +} + +module.exports = VoiceWebSocket; diff --git a/node_modules/discord.js/src/client/voice/dispatcher/StreamDispatcher.js b/node_modules/discord.js/src/client/voice/dispatcher/StreamDispatcher.js new file mode 100644 index 0000000..e08a365 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/dispatcher/StreamDispatcher.js @@ -0,0 +1,307 @@ +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, sd, 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; + + /** + * 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); + } + + /** + * 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/node_modules/discord.js/src/client/voice/opus/BaseOpusEngine.js b/node_modules/discord.js/src/client/voice/opus/BaseOpusEngine.js new file mode 100644 index 0000000..6c3ba6e --- /dev/null +++ b/node_modules/discord.js/src/client/voice/opus/BaseOpusEngine.js @@ -0,0 +1,15 @@ +class BaseOpus { + constructor(player) { + this.player = player; + } + + encode(buffer) { + return buffer; + } + + decode(buffer) { + return buffer; + } +} + +module.exports = BaseOpus; diff --git a/node_modules/discord.js/src/client/voice/opus/NodeOpusEngine.js b/node_modules/discord.js/src/client/voice/opus/NodeOpusEngine.js new file mode 100644 index 0000000..10f287b --- /dev/null +++ b/node_modules/discord.js/src/client/voice/opus/NodeOpusEngine.js @@ -0,0 +1,27 @@ +const OpusEngine = require('./BaseOpusEngine'); + +let opus; + +class NodeOpusEngine extends OpusEngine { + constructor(player) { + super(player); + try { + opus = require('node-opus'); + } catch (err) { + throw err; + } + this.encoder = new opus.OpusEncoder(48000, 2); + } + + encode(buffer) { + super.encode(buffer); + return this.encoder.encode(buffer, 1920); + } + + decode(buffer) { + super.decode(buffer); + return this.encoder.decode(buffer, 1920); + } +} + +module.exports = NodeOpusEngine; diff --git a/node_modules/discord.js/src/client/voice/opus/OpusEngineList.js b/node_modules/discord.js/src/client/voice/opus/OpusEngineList.js new file mode 100644 index 0000000..ffd512a --- /dev/null +++ b/node_modules/discord.js/src/client/voice/opus/OpusEngineList.js @@ -0,0 +1,24 @@ +const list = [ + require('./NodeOpusEngine'), + require('./OpusScriptEngine'), +]; + +function fetch(Encoder) { + try { + return new Encoder(); + } catch (err) { + return null; + } +} + +exports.add = encoder => { + list.push(encoder); +}; + +exports.fetch = () => { + for (const encoder of list) { + const fetched = fetch(encoder); + if (fetched) return fetched; + } + throw new Error('Couldn\'t find an Opus engine.'); +}; diff --git a/node_modules/discord.js/src/client/voice/opus/OpusScriptEngine.js b/node_modules/discord.js/src/client/voice/opus/OpusScriptEngine.js new file mode 100644 index 0000000..33b4ff5 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/opus/OpusScriptEngine.js @@ -0,0 +1,27 @@ +const OpusEngine = require('./BaseOpusEngine'); + +let OpusScript; + +class NodeOpusEngine extends OpusEngine { + constructor(player) { + super(player); + try { + OpusScript = require('opusscript'); + } catch (err) { + throw err; + } + this.encoder = new OpusScript(48000, 2); + } + + encode(buffer) { + super.encode(buffer); + return this.encoder.encode(buffer, 960); + } + + decode(buffer) { + super.decode(buffer); + return this.encoder.decode(buffer); + } +} + +module.exports = NodeOpusEngine; diff --git a/node_modules/discord.js/src/client/voice/pcm/ConverterEngine.js b/node_modules/discord.js/src/client/voice/pcm/ConverterEngine.js new file mode 100644 index 0000000..6b7502f --- /dev/null +++ b/node_modules/discord.js/src/client/voice/pcm/ConverterEngine.js @@ -0,0 +1,14 @@ +const EventEmitter = require('events').EventEmitter; + +class ConverterEngine extends EventEmitter { + constructor(player) { + super(); + this.player = player; + } + + createConvertStream() { + return; + } +} + +module.exports = ConverterEngine; diff --git a/node_modules/discord.js/src/client/voice/pcm/ConverterEngineList.js b/node_modules/discord.js/src/client/voice/pcm/ConverterEngineList.js new file mode 100644 index 0000000..56d430e --- /dev/null +++ b/node_modules/discord.js/src/client/voice/pcm/ConverterEngineList.js @@ -0,0 +1 @@ +exports.fetch = () => require('./FfmpegConverterEngine'); diff --git a/node_modules/discord.js/src/client/voice/pcm/FfmpegConverterEngine.js b/node_modules/discord.js/src/client/voice/pcm/FfmpegConverterEngine.js new file mode 100644 index 0000000..8fb725b --- /dev/null +++ b/node_modules/discord.js/src/client/voice/pcm/FfmpegConverterEngine.js @@ -0,0 +1,86 @@ +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/node_modules/discord.js/src/client/voice/player/AudioPlayer.js b/node_modules/discord.js/src/client/voice/player/AudioPlayer.js new file mode 100644 index 0000000..96c6c24 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/player/AudioPlayer.js @@ -0,0 +1,80 @@ +const PCMConverters = require('../pcm/ConverterEngineList'); +const OpusEncoders = require('../opus/OpusEngineList'); +const EventEmitter = require('events').EventEmitter; +const StreamDispatcher = require('../dispatcher/StreamDispatcher'); + +/** + * 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.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.streamingData = { + channels: 2, + count: 0, + sequence: 0, + timestamp: 0, + pausedTime: 0, + }; + this.voiceConnection.on('closing', () => this.cleanup(null, 'voice connection closing')); + } + + 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/node_modules/discord.js/src/client/voice/player/BasePlayer.js b/node_modules/discord.js/src/client/voice/player/BasePlayer.js new file mode 100644 index 0000000..d5285cd --- /dev/null +++ b/node_modules/discord.js/src/client/voice/player/BasePlayer.js @@ -0,0 +1,121 @@ +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/node_modules/discord.js/src/client/voice/player/DefaultPlayer.js b/node_modules/discord.js/src/client/voice/player/DefaultPlayer.js new file mode 100644 index 0000000..b465e8c --- /dev/null +++ b/node_modules/discord.js/src/client/voice/player/DefaultPlayer.js @@ -0,0 +1,19 @@ +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; diff --git a/node_modules/discord.js/src/client/voice/receiver/VoiceReadable.js b/node_modules/discord.js/src/client/voice/receiver/VoiceReadable.js new file mode 100644 index 0000000..50ace27 --- /dev/null +++ b/node_modules/discord.js/src/client/voice/receiver/VoiceReadable.js @@ -0,0 +1,19 @@ +const Readable = require('stream').Readable; + +class VoiceReadable extends Readable { + constructor() { + super(); + this._packets = []; + this.open = true; + } + + _read() { + return; + } + + _push(d) { + if (this.open) this.push(d); + } +} + +module.exports = VoiceReadable; diff --git a/node_modules/discord.js/src/client/voice/receiver/VoiceReceiver.js b/node_modules/discord.js/src/client/voice/receiver/VoiceReceiver.js new file mode 100644 index 0000000..bc9156f --- /dev/null +++ b/node_modules/discord.js/src/client/voice/receiver/VoiceReceiver.js @@ -0,0 +1,154 @@ +const EventEmitter = require('events').EventEmitter; +const NaCl = require('tweetnacl'); +const Readable = require('./VoiceReadable'); + +const nonce = new Buffer(24); +nonce.fill(0); + +/** + * Receives voice data from a voice connection. + * ```js + * // obtained using: + * voiceChannel.join().then(connection => { + * const receiver = connection.createReceiver(); + * }); + * ``` + * @extends {EventEmitter} + */ +class VoiceReceiver extends EventEmitter { + constructor(connection) { + super(); + /* + need a queue because we don't get the ssrc of the user speaking until after the first few packets, + so we queue up unknown SSRCs until they become known, then empty the queue. + */ + this.queues = new Map(); + this.pcmStreams = new Map(); + this.opusStreams = new Map(); + + /** + * Whether or not this receiver has been destroyed. + * @type {boolean} + */ + this.destroyed = false; + + /** + * The VoiceConnection that instantiated this + * @type {VoiceConnection} + */ + this.voiceConnection = connection; + + this._listener = msg => { + const ssrc = +msg.readUInt32BE(8).toString(10); + const user = this.voiceConnection.ssrcMap.get(ssrc); + if (!user) { + if (!this.queues.has(ssrc)) this.queues.set(ssrc, []); + this.queues.get(ssrc).push(msg); + } else { + if (this.queues.get(ssrc)) { + this.queues.get(ssrc).push(msg); + this.queues.get(ssrc).map(m => this.handlePacket(m, user)); + this.queues.delete(ssrc); + return; + } + this.handlePacket(msg, user); + } + }; + this.voiceConnection.sockets.udp.socket.on('message', this._listener); + } + + /** + * If this VoiceReceiver has been destroyed, running `recreate()` will recreate the listener. + * This avoids you having to create a new receiver. + * <info>Any streams that you had prior to destroying the receiver will not be recreated.</info> + */ + recreate() { + if (!this.destroyed) return; + this.voiceConnection.sockets.udp.socket.on('message', this._listener); + this.destroyed = false; + return; + } + + /** + * Destroy this VoiceReceiver, also ending any streams that it may be controlling. + */ + 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 stream of this.opusStreams) { + stream[1]._push(null); + this.opusStreams.delete(stream[0]); + } + this.destroyed = true; + } + + /** + * 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. + * @param {UserResolvable} user The user to create the stream for + * @returns {ReadableStream} + */ + createOpusStream(user) { + user = this.voiceConnection.voiceManager.client.resolver.resolveUser(user); + if (!user) throw new Error('Couldn\'t resolve the user to create Opus stream.'); + if (this.opusStreams.get(user.id)) throw new Error('There is already an existing stream for that user.'); + const stream = new Readable(); + this.opusStreams.set(user.id, stream); + return stream; + } + + /** + * Creates a readable stream for a user that provides PCM data while the user is speaking. When the user + * stops speaking, the stream is destroyed. The stream is 32-bit signed stereo PCM at 48KHz. + * @param {UserResolvable} user The user to create the stream for + * @returns {ReadableStream} + */ + createPCMStream(user) { + user = this.voiceConnection.voiceManager.client.resolver.resolveUser(user); + if (!user) throw new Error('Couldn\'t resolve the user to create PCM stream.'); + if (this.pcmStreams.get(user.id)) throw new Error('There is already an existing stream for that user.'); + const stream = new Readable(); + this.pcmStreams.set(user.id, stream); + return stream; + } + + handlePacket(msg, user) { + msg.copy(nonce, 0, 0, 12); + let data = NaCl.secretbox.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key); + if (!data) { + /** + * Emitted whenever a voice packet cannot be decrypted + * @event VoiceReceiver#warn + * @param {string} message The warning message + */ + this.emit('warn', 'Failed to decrypt voice packet'); + return; + } + data = new Buffer(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). + * @event VoiceReceiver#opus + * @param {User} user The user that is sending the buffer (is speaking) + * @param {Buffer} buffer The opus buffer + */ + this.emit('opus', user, data); + if (this.listenerCount('pcm') > 0 || this.pcmStreams.size > 0) { + /** + * 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. + * @event VoiceReceiver#pcm + * @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); + } + } +} + +module.exports = VoiceReceiver; diff --git a/node_modules/discord.js/src/client/voice/util/SecretKey.js b/node_modules/discord.js/src/client/voice/util/SecretKey.js new file mode 100644 index 0000000..508a1ba --- /dev/null +++ b/node_modules/discord.js/src/client/voice/util/SecretKey.js @@ -0,0 +1,16 @@ +/** + * Represents a Secret Key used in encryption over voice + * @private + */ +class SecretKey { + constructor(key) { + /** + * The key used for encryption + * @type {Uint8Array} + */ + this.key = new Uint8Array(new ArrayBuffer(key.length)); + for (const index in key) this.key[index] = key[index]; + } +} + +module.exports = SecretKey; |
