mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-09 16:13:31 +01:00
541 lines
15 KiB
JavaScript
541 lines
15 KiB
JavaScript
const VoiceWebSocket = require('./VoiceWebSocket');
|
|
const VoiceUDP = require('./VoiceUDPClient');
|
|
const Util = require('../../util/Util');
|
|
const Constants = require('../../util/Constants');
|
|
const AudioPlayer = require('./player/AudioPlayer');
|
|
const VoiceReceiver = require('./receiver/VoiceReceiver');
|
|
const EventEmitter = require('events').EventEmitter;
|
|
const Prism = require('prism-media');
|
|
|
|
/**
|
|
* Represents a connection to a guild's voice server.
|
|
* ```js
|
|
* // Obtained using:
|
|
* voiceChannel.join()
|
|
* .then(connection => {
|
|
*
|
|
* });
|
|
* ```
|
|
* @extends {EventEmitter}
|
|
*/
|
|
class VoiceConnection extends EventEmitter {
|
|
constructor(voiceManager, channel) {
|
|
super();
|
|
|
|
/**
|
|
* The voice manager that instantiated this connection
|
|
* @type {ClientVoiceManager}
|
|
*/
|
|
this.voiceManager = voiceManager;
|
|
|
|
/**
|
|
* The client that instantiated this connection
|
|
* @type {Client}
|
|
*/
|
|
this.client = voiceManager.client;
|
|
|
|
/**
|
|
* @external Prism
|
|
* @see {@link https://github.com/hydrabolt/prism-media}
|
|
*/
|
|
|
|
/**
|
|
* The audio transcoder for this connection
|
|
* @type {Prism}
|
|
*/
|
|
this.prism = new Prism();
|
|
|
|
/**
|
|
* The voice channel this connection is currently serving
|
|
* @type {VoiceChannel}
|
|
*/
|
|
this.channel = channel;
|
|
|
|
/**
|
|
* The current status of the voice connection
|
|
* @type {VoiceStatus}
|
|
*/
|
|
this.status = Constants.VoiceStatus.AUTHENTICATING;
|
|
|
|
/**
|
|
* 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 = {};
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
|
|
/**
|
|
* Map SSRC to speaking values
|
|
* @type {Map<number, boolean>}
|
|
* @private
|
|
*/
|
|
this.ssrcMap = new Map();
|
|
|
|
/**
|
|
* Object that wraps contains the `ws` and `udp` sockets of this voice connection
|
|
* @type {Object}
|
|
* @private
|
|
*/
|
|
this.sockets = {};
|
|
|
|
this.authenticate();
|
|
}
|
|
|
|
/**
|
|
* The current stream dispatcher (if any)
|
|
* @type {?StreamDispatcher}
|
|
* @readonly
|
|
*/
|
|
get dispatcher() {
|
|
return this.player.dispatcher;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
if (this.status !== Constants.VoiceStatus.CONNECTED) return;
|
|
this.speaking = value;
|
|
this.sockets.ws.sendPacket({
|
|
op: Constants.VoiceOPCodes.SPEAKING,
|
|
d: {
|
|
speaking: true,
|
|
delay: 0,
|
|
},
|
|
}).catch(e => {
|
|
this.emit('debug', e);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sends a request to the main gateway to join a voice channel.
|
|
* @param {Object} [options] The options to provide
|
|
*/
|
|
sendVoiceStateUpdate(options = {}) {
|
|
options = Util.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 voice servers.
|
|
* @param {string} token The voice token
|
|
* @param {string} endpoint The voice endpoint
|
|
* @returns {void}
|
|
*/
|
|
setTokenAndEndpoint(token, endpoint) {
|
|
if (!endpoint) {
|
|
// Signifies awaiting endpoint stage
|
|
return;
|
|
}
|
|
|
|
if (!token) {
|
|
this.authenticateFailed('Token not provided from voice server packet.');
|
|
return;
|
|
}
|
|
|
|
endpoint = endpoint.match(/([^:]*)/)[0];
|
|
|
|
if (!endpoint) {
|
|
this.authenticateFailed('Invalid endpoint received.');
|
|
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) {
|
|
this.client.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) {
|
|
this.client.clearTimeout(this.connectTimeout);
|
|
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 {
|
|
/**
|
|
* Emitted whenever the connection encounters an error.
|
|
* @event VoiceConnection#error
|
|
* @param {Error} error The encountered error
|
|
*/
|
|
this.emit('error', new Error(reason));
|
|
}
|
|
this.status = Constants.VoiceStatus.DISCONNECTED;
|
|
}
|
|
|
|
/**
|
|
* 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.sendVoiceStateUpdate({
|
|
channel_id: null,
|
|
});
|
|
|
|
this._disconnect();
|
|
}
|
|
|
|
/**
|
|
* Internally disconnects (doesn't send disconnect packet).
|
|
* @private
|
|
*/
|
|
_disconnect() {
|
|
this.player.destroy();
|
|
this.cleanup();
|
|
this.status = Constants.VoiceStatus.DISCONNECTED;
|
|
/**
|
|
* Emitted when the voice connection disconnects.
|
|
* @event VoiceConnection#disconnect
|
|
*/
|
|
this.emit('disconnect');
|
|
}
|
|
|
|
/**
|
|
* Cleans up after disconnect.
|
|
* @private
|
|
*/
|
|
cleanup() {
|
|
const { ws, udp } = this.sockets;
|
|
|
|
if (ws) {
|
|
ws.removeAllListeners('error');
|
|
ws.removeAllListeners('ready');
|
|
ws.removeAllListeners('sessionDescription');
|
|
ws.removeAllListeners('speaking');
|
|
ws.shutdown();
|
|
}
|
|
|
|
if (udp) udp.removeAllListeners('error');
|
|
|
|
this.sockets.ws = null;
|
|
this.sockets.udp = null;
|
|
}
|
|
|
|
/**
|
|
* Connect the voice connection.
|
|
* @private
|
|
*/
|
|
connect() {
|
|
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);
|
|
|
|
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, ip }) {
|
|
this.authentication.port = port;
|
|
this.authentication.ssrc = ssrc;
|
|
this.sockets.udp.createUDPSocket(ip);
|
|
}
|
|
|
|
/**
|
|
* 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.status === Constants.VoiceStatus.CONNECTED) this.emit('speaking', user, speaking);
|
|
guild._memberSpeakUpdate(user_id, 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
|
|
* @property {number|string} [bitrate=48000] The bitrate (quality) of the audio.
|
|
* If set to 'auto', the voice channel's bitrate will be used
|
|
*/
|
|
|
|
/**
|
|
* Play the given file in the voice connection.
|
|
* @param {string} file The absolute 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.player.playUnknownStream(`file:${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, options) {
|
|
return this.player.playUnknownStream(input, 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, options) {
|
|
return this.player.playUnknownStream(stream, options);
|
|
}
|
|
|
|
/**
|
|
* Plays a stream of 16-bit signed stereo PCM.
|
|
* @param {ReadableStream} stream The audio stream to play
|
|
* @param {StreamOptions} [options] Options for playing the stream
|
|
* @returns {StreamDispatcher}
|
|
*/
|
|
playConvertedStream(stream, options) {
|
|
return this.player.playPCMStream(stream, options);
|
|
}
|
|
|
|
/**
|
|
* Plays an Opus encoded stream.
|
|
* <warn>Note that inline volume is not compatible with this method.</warn>
|
|
* @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
|
|
* @param {StreamOptions} [options] Options for playing the stream
|
|
* @returns {StreamDispatcher}
|
|
* @example
|
|
* // Play a broadcast
|
|
* const broadcast = client
|
|
* .createVoiceBroadcast()
|
|
* .playFile('./test.mp3');
|
|
* const dispatcher = voiceConnection.playBroadcast(broadcast);
|
|
*/
|
|
playBroadcast(broadcast, options) {
|
|
return this.player.playBroadcast(broadcast, 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;
|