chore: monorepo setup (#7175)

This commit is contained in:
Noel
2022-01-07 17:18:25 +01:00
committed by GitHub
parent 780b7ed39f
commit 16390efe6e
504 changed files with 25459 additions and 22830 deletions

View File

@@ -0,0 +1,189 @@
import { GatewayOpcodes } from 'discord-api-types/v9';
import type { AudioPlayer } from './audio';
import type { VoiceConnection } from './VoiceConnection';
export interface JoinConfig {
guildId: string;
channelId: string | null;
selfDeaf: boolean;
selfMute: boolean;
group: string;
}
/**
* Sends a voice state update to the main websocket shard of a guild, to indicate joining/leaving/moving across
* voice channels.
*
* @param config - The configuration to use when joining the voice channel
*/
export function createJoinVoiceChannelPayload(config: JoinConfig) {
return {
op: GatewayOpcodes.VoiceStateUpdate,
d: {
guild_id: config.guildId,
channel_id: config.channelId,
self_deaf: config.selfDeaf,
self_mute: config.selfMute,
},
};
}
// Voice Connections
const groups = new Map<string, Map<string, VoiceConnection>>();
groups.set('default', new Map());
function getOrCreateGroup(group: string) {
const existing = groups.get(group);
if (existing) return existing;
const map = new Map<string, VoiceConnection>();
groups.set(group, map);
return map;
}
/**
* Retrieves the map of group names to maps of voice connections. By default, all voice connections
* are created under the 'default' group.
*
* @returns The group map
*/
export function getGroups() {
return groups;
}
/**
* Retrieves all the voice connections under the 'default' group.
*
* @param group - The group to look up
*
* @returns The map of voice connections
*/
export function getVoiceConnections(group?: 'default'): Map<string, VoiceConnection>;
/**
* Retrieves all the voice connections under the given group name.
*
* @param group - The group to look up
*
* @returns The map of voice connections
*/
export function getVoiceConnections(group: string): Map<string, VoiceConnection> | undefined;
/**
* Retrieves all the voice connections under the given group name. Defaults to the 'default' group.
*
* @param group - The group to look up
*
* @returns The map of voice connections
*/
export function getVoiceConnections(group = 'default') {
return groups.get(group);
}
/**
* Finds a voice connection with the given guild id and group. Defaults to the 'default' group.
*
* @param guildId - The guild id of the voice connection
* @param group - the group that the voice connection was registered with
*
* @returns The voice connection, if it exists
*/
export function getVoiceConnection(guildId: string, group = 'default') {
return getVoiceConnections(group)?.get(guildId);
}
export function untrackVoiceConnection(voiceConnection: VoiceConnection) {
return getVoiceConnections(voiceConnection.joinConfig.group)?.delete(voiceConnection.joinConfig.guildId);
}
export function trackVoiceConnection(voiceConnection: VoiceConnection) {
return getOrCreateGroup(voiceConnection.joinConfig.group).set(voiceConnection.joinConfig.guildId, voiceConnection);
}
// Audio Players
// Each audio packet is 20ms long
const FRAME_LENGTH = 20;
let audioCycleInterval: NodeJS.Timeout | undefined;
let nextTime = -1;
/**
* A list of created audio players that are still active and haven't been destroyed.
*/
const audioPlayers: AudioPlayer[] = [];
/**
* Called roughly every 20 milliseconds. Dispatches audio from all players, and then gets the players to prepare
* the next audio frame.
*/
function audioCycleStep() {
if (nextTime === -1) return;
nextTime += FRAME_LENGTH;
const available = audioPlayers.filter((player) => player.checkPlayable());
// eslint-disable-next-line @typescript-eslint/dot-notation
available.forEach((player) => player['_stepDispatch']());
prepareNextAudioFrame(available);
}
/**
* Recursively gets the players that have been passed as parameters to prepare audio frames that can be played
* at the start of the next cycle.
*/
function prepareNextAudioFrame(players: AudioPlayer[]) {
const nextPlayer = players.shift();
if (!nextPlayer) {
if (nextTime !== -1) {
audioCycleInterval = setTimeout(() => audioCycleStep(), nextTime - Date.now());
}
return;
}
// eslint-disable-next-line @typescript-eslint/dot-notation
nextPlayer['_stepPrepare']();
// setImmediate to avoid long audio player chains blocking other scheduled tasks
setImmediate(() => prepareNextAudioFrame(players));
}
/**
* Checks whether or not the given audio player is being driven by the data store clock.
*
* @param target - The target to test for
*
* @returns `true` if it is being tracked, `false` otherwise
*/
export function hasAudioPlayer(target: AudioPlayer) {
return audioPlayers.includes(target);
}
/**
* Adds an audio player to the data store tracking list, if it isn't already there.
*
* @param player - The player to track
*/
export function addAudioPlayer(player: AudioPlayer) {
if (hasAudioPlayer(player)) return player;
audioPlayers.push(player);
if (audioPlayers.length === 1) {
nextTime = Date.now();
setImmediate(() => audioCycleStep());
}
return player;
}
/**
* Removes an audio player from the data store tracking list, if it is present there.
*/
export function deleteAudioPlayer(player: AudioPlayer) {
const index = audioPlayers.indexOf(player);
if (index === -1) return;
audioPlayers.splice(index, 1);
if (audioPlayers.length === 0) {
nextTime = -1;
if (typeof audioCycleInterval !== 'undefined') clearTimeout(audioCycleInterval);
}
}

View File

@@ -0,0 +1,723 @@
import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v9';
import type { CreateVoiceConnectionOptions } from '.';
import type { AudioPlayer } from './audio/AudioPlayer';
import type { PlayerSubscription } from './audio/PlayerSubscription';
import {
getVoiceConnection,
createJoinVoiceChannelPayload,
trackVoiceConnection,
JoinConfig,
untrackVoiceConnection,
} from './DataStore';
import type { DiscordGatewayAdapterImplementerMethods } from './util/adapter';
import { Networking, NetworkingState, NetworkingStatusCode } from './networking/Networking';
import { Awaited, noop } from './util/util';
import { TypedEmitter } from 'tiny-typed-emitter';
import { VoiceReceiver } from './receive';
import type { VoiceWebSocket, VoiceUDPSocket } from './networking';
/**
* The various status codes a voice connection can hold at any one time.
*/
export enum VoiceConnectionStatus {
/**
* Sending a packet to the main Discord gateway to indicate we want to change our voice state.
*/
Signalling = 'signalling',
/**
* The `VOICE_SERVER_UPDATE` and `VOICE_STATE_UPDATE` packets have been received, now attempting to establish a voice connection.
*/
Connecting = 'connecting',
/**
* A voice connection has been established, and is ready to be used.
*/
Ready = 'ready',
/**
* The voice connection has either been severed or not established.
*/
Disconnected = 'disconnected',
/**
* The voice connection has been destroyed and untracked, it cannot be reused.
*/
Destroyed = 'destroyed',
}
/**
* The state that a VoiceConnection will be in when it is waiting to receive a VOICE_SERVER_UPDATE and
* VOICE_STATE_UPDATE packet from Discord, provided by the adapter.
*/
export interface VoiceConnectionSignallingState {
status: VoiceConnectionStatus.Signalling;
subscription?: PlayerSubscription;
adapter: DiscordGatewayAdapterImplementerMethods;
}
/**
* The reasons a voice connection can be in the disconnected state.
*/
export enum VoiceConnectionDisconnectReason {
/**
* When the WebSocket connection has been closed.
*/
WebSocketClose,
/**
* When the adapter was unable to send a message requested by the VoiceConnection.
*/
AdapterUnavailable,
/**
* When a VOICE_SERVER_UPDATE packet is received with a null endpoint, causing the connection to be severed.
*/
EndpointRemoved,
/**
* When a manual disconnect was requested.
*/
Manual,
}
/**
* The state that a VoiceConnection will be in when it is not connected to a Discord voice server nor is
* it attempting to connect. You can manually attempt to reconnect using VoiceConnection#reconnect.
*/
export interface VoiceConnectionDisconnectedBaseState {
status: VoiceConnectionStatus.Disconnected;
subscription?: PlayerSubscription;
adapter: DiscordGatewayAdapterImplementerMethods;
}
/**
* The state that a VoiceConnection will be in when it is not connected to a Discord voice server nor is
* it attempting to connect. You can manually attempt to reconnect using VoiceConnection#reconnect.
*/
export interface VoiceConnectionDisconnectedOtherState extends VoiceConnectionDisconnectedBaseState {
reason: Exclude<VoiceConnectionDisconnectReason, VoiceConnectionDisconnectReason.WebSocketClose>;
}
/**
* The state that a VoiceConnection will be in when its WebSocket connection was closed.
* You can manually attempt to reconnect using VoiceConnection#reconnect.
*/
export interface VoiceConnectionDisconnectedWebSocketState extends VoiceConnectionDisconnectedBaseState {
reason: VoiceConnectionDisconnectReason.WebSocketClose;
/**
* The close code of the WebSocket connection to the Discord voice server.
*/
closeCode: number;
}
/**
* The states that a VoiceConnection can be in when it is not connected to a Discord voice server nor is
* it attempting to connect. You can manually attempt to connect using VoiceConnection#reconnect.
*/
export type VoiceConnectionDisconnectedState =
| VoiceConnectionDisconnectedOtherState
| VoiceConnectionDisconnectedWebSocketState;
/**
* The state that a VoiceConnection will be in when it is establishing a connection to a Discord
* voice server.
*/
export interface VoiceConnectionConnectingState {
status: VoiceConnectionStatus.Connecting;
networking: Networking;
subscription?: PlayerSubscription;
adapter: DiscordGatewayAdapterImplementerMethods;
}
/**
* The state that a VoiceConnection will be in when it has an active connection to a Discord
* voice server.
*/
export interface VoiceConnectionReadyState {
status: VoiceConnectionStatus.Ready;
networking: Networking;
subscription?: PlayerSubscription;
adapter: DiscordGatewayAdapterImplementerMethods;
}
/**
* The state that a VoiceConnection will be in when it has been permanently been destroyed by the
* user and untracked by the library. It cannot be reconnected, instead, a new VoiceConnection
* needs to be established.
*/
export interface VoiceConnectionDestroyedState {
status: VoiceConnectionStatus.Destroyed;
}
/**
* The various states that a voice connection can be in.
*/
export type VoiceConnectionState =
| VoiceConnectionSignallingState
| VoiceConnectionDisconnectedState
| VoiceConnectionConnectingState
| VoiceConnectionReadyState
| VoiceConnectionDestroyedState;
export type VoiceConnectionEvents = {
error: (error: Error) => Awaited<void>;
debug: (message: string) => Awaited<void>;
stateChange: (oldState: VoiceConnectionState, newState: VoiceConnectionState) => Awaited<void>;
} & {
[status in VoiceConnectionStatus]: (
oldState: VoiceConnectionState,
newState: VoiceConnectionState & { status: status },
) => Awaited<void>;
};
/**
* A connection to the voice server of a Guild, can be used to play audio in voice channels.
*/
export class VoiceConnection extends TypedEmitter<VoiceConnectionEvents> {
/**
* The number of consecutive rejoin attempts. Initially 0, and increments for each rejoin.
* When a connection is successfully established, it resets to 0.
*/
public rejoinAttempts: number;
/**
* The state of the voice connection.
*/
private _state: VoiceConnectionState;
/**
* A configuration storing all the data needed to reconnect to a Guild's voice server.
*
* @internal
*/
public readonly joinConfig: JoinConfig;
/**
* The two packets needed to successfully establish a voice connection. They are received
* from the main Discord gateway after signalling to change the voice state.
*/
private readonly packets: {
server: GatewayVoiceServerUpdateDispatchData | undefined;
state: GatewayVoiceStateUpdateDispatchData | undefined;
};
/**
* The receiver of this voice connection. You should join the voice channel with `selfDeaf` set
* to false for this feature to work properly.
*/
public readonly receiver: VoiceReceiver;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new voice connection.
*
* @param joinConfig - The data required to establish the voice connection
* @param options - The options used to create this voice connection
*/
public constructor(joinConfig: JoinConfig, { debug, adapterCreator }: CreateVoiceConnectionOptions) {
super();
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
this.rejoinAttempts = 0;
this.receiver = new VoiceReceiver(this);
this.onNetworkingClose = this.onNetworkingClose.bind(this);
this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this);
this.onNetworkingError = this.onNetworkingError.bind(this);
this.onNetworkingDebug = this.onNetworkingDebug.bind(this);
const adapter = adapterCreator({
onVoiceServerUpdate: (data) => this.addServerPacket(data),
onVoiceStateUpdate: (data) => this.addStatePacket(data),
destroy: () => this.destroy(false),
});
this._state = { status: VoiceConnectionStatus.Signalling, adapter };
this.packets = {
server: undefined,
state: undefined,
};
this.joinConfig = joinConfig;
}
/**
* The current state of the voice connection.
*/
public get state() {
return this._state;
}
/**
* Updates the state of the voice connection, performing clean-up operations where necessary.
*/
public set state(newState: VoiceConnectionState) {
const oldState = this._state;
const oldNetworking: Networking | undefined = Reflect.get(oldState, 'networking');
const newNetworking: Networking | undefined = Reflect.get(newState, 'networking');
const oldSubscription: PlayerSubscription | undefined = Reflect.get(oldState, 'subscription');
const newSubscription: PlayerSubscription | undefined = Reflect.get(newState, 'subscription');
if (oldNetworking !== newNetworking) {
if (oldNetworking) {
oldNetworking.on('error', noop);
oldNetworking.off('debug', this.onNetworkingDebug);
oldNetworking.off('error', this.onNetworkingError);
oldNetworking.off('close', this.onNetworkingClose);
oldNetworking.off('stateChange', this.onNetworkingStateChange);
oldNetworking.destroy();
}
if (newNetworking) this.updateReceiveBindings(newNetworking.state, oldNetworking?.state);
}
if (newState.status === VoiceConnectionStatus.Ready) {
this.rejoinAttempts = 0;
} else if (newState.status === VoiceConnectionStatus.Destroyed) {
for (const stream of this.receiver.subscriptions.values()) {
if (!stream.destroyed) stream.destroy();
}
}
// If destroyed, the adapter can also be destroyed so it can be cleaned up by the user
if (oldState.status !== VoiceConnectionStatus.Destroyed && newState.status === VoiceConnectionStatus.Destroyed) {
oldState.adapter.destroy();
}
this._state = newState;
if (oldSubscription && oldSubscription !== newSubscription) {
oldSubscription.unsubscribe();
}
this.emit('stateChange', oldState, newState);
if (oldState.status !== newState.status) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.emit(newState.status, oldState, newState as any);
}
}
/**
* Registers a `VOICE_SERVER_UPDATE` packet to the voice connection. This will cause it to reconnect using the
* new data provided in the packet.
*
* @param packet - The received `VOICE_SERVER_UPDATE` packet
*/
private addServerPacket(packet: GatewayVoiceServerUpdateDispatchData) {
this.packets.server = packet;
if (packet.endpoint) {
this.configureNetworking();
} else if (this.state.status !== VoiceConnectionStatus.Destroyed) {
this.state = {
...this.state,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.EndpointRemoved,
};
}
}
/**
* Registers a `VOICE_STATE_UPDATE` packet to the voice connection. Most importantly, it stores the id of the
* channel that the client is connected to.
*
* @param packet - The received `VOICE_STATE_UPDATE` packet
*/
private addStatePacket(packet: GatewayVoiceStateUpdateDispatchData) {
this.packets.state = packet;
if (typeof packet.self_deaf !== 'undefined') this.joinConfig.selfDeaf = packet.self_deaf;
if (typeof packet.self_mute !== 'undefined') this.joinConfig.selfMute = packet.self_mute;
if (packet.channel_id) this.joinConfig.channelId = packet.channel_id;
/*
the channel_id being null doesn't necessarily mean it was intended for the client to leave the voice channel
as it may have disconnected due to network failure. This will be gracefully handled once the voice websocket
dies, and then it is up to the user to decide how they wish to handle this.
*/
}
/**
* Called when the networking state changes, and the new ws/udp packet/message handlers need to be rebound
* to the new instances.
* @param newState - The new networking state
* @param oldState - The old networking state, if there is one
*/
private updateReceiveBindings(newState: NetworkingState, oldState?: NetworkingState) {
const oldWs = Reflect.get(oldState ?? {}, 'ws') as VoiceWebSocket | undefined;
const newWs = Reflect.get(newState, 'ws') as VoiceWebSocket | undefined;
const oldUdp = Reflect.get(oldState ?? {}, 'udp') as VoiceUDPSocket | undefined;
const newUdp = Reflect.get(newState, 'udp') as VoiceUDPSocket | undefined;
if (oldWs !== newWs) {
oldWs?.off('packet', this.receiver.onWsPacket);
newWs?.on('packet', this.receiver.onWsPacket);
}
if (oldUdp !== newUdp) {
oldUdp?.off('message', this.receiver.onUdpMessage);
newUdp?.on('message', this.receiver.onUdpMessage);
}
this.receiver.connectionData = Reflect.get(newState, 'connectionData') ?? {};
}
/**
* Attempts to configure a networking instance for this voice connection using the received packets.
* Both packets are required, and any existing networking instance will be destroyed.
*
* @remarks
* This is called when the voice server of the connection changes, e.g. if the bot is moved into a
* different channel in the same guild but has a different voice server. In this instance, the connection
* needs to be re-established to the new voice server.
*
* The connection will transition to the Connecting state when this is called.
*/
public configureNetworking() {
const { server, state } = this.packets;
if (!server || !state || this.state.status === VoiceConnectionStatus.Destroyed || !server.endpoint) return;
const networking = new Networking(
{
endpoint: server.endpoint,
serverId: server.guild_id,
token: server.token,
sessionId: state.session_id,
userId: state.user_id,
},
Boolean(this.debug),
);
networking.once('close', this.onNetworkingClose);
networking.on('stateChange', this.onNetworkingStateChange);
networking.on('error', this.onNetworkingError);
networking.on('debug', this.onNetworkingDebug);
this.state = {
...this.state,
status: VoiceConnectionStatus.Connecting,
networking,
};
}
/**
* Called when the networking instance for this connection closes. If the close code is 4014 (do not reconnect),
* the voice connection will transition to the Disconnected state which will store the close code. You can
* decide whether or not to reconnect when this occurs by listening for the state change and calling reconnect().
*
* @remarks
* If the close code was anything other than 4014, it is likely that the closing was not intended, and so the
* VoiceConnection will signal to Discord that it would like to rejoin the channel. This automatically attempts
* to re-establish the connection. This would be seen as a transition from the Ready state to the Signalling state.
*
* @param code - The close code
*/
private onNetworkingClose(code: number) {
if (this.state.status === VoiceConnectionStatus.Destroyed) return;
// If networking closes, try to connect to the voice channel again.
if (code === 4014) {
// Disconnected - networking is already destroyed here
this.state = {
...this.state,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.WebSocketClose,
closeCode: code,
};
} else {
this.state = {
...this.state,
status: VoiceConnectionStatus.Signalling,
};
this.rejoinAttempts++;
if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
this.state = {
...this.state,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
};
}
}
}
/**
* Called when the state of the networking instance changes. This is used to derive the state of the voice connection.
*
* @param oldState - The previous state
* @param newState - The new state
*/
private onNetworkingStateChange(oldState: NetworkingState, newState: NetworkingState) {
this.updateReceiveBindings(newState, oldState);
if (oldState.code === newState.code) return;
if (this.state.status !== VoiceConnectionStatus.Connecting && this.state.status !== VoiceConnectionStatus.Ready)
return;
if (newState.code === NetworkingStatusCode.Ready) {
this.state = {
...this.state,
status: VoiceConnectionStatus.Ready,
};
} else if (newState.code !== NetworkingStatusCode.Closed) {
this.state = {
...this.state,
status: VoiceConnectionStatus.Connecting,
};
}
}
/**
* Propagates errors from the underlying network instance.
*
* @param error - The error to propagate
*/
private onNetworkingError(error: Error) {
this.emit('error', error);
}
/**
* Propagates debug messages from the underlying network instance.
*
* @param message - The debug message to propagate
*/
private onNetworkingDebug(message: string) {
this.debug?.(`[NW] ${message}`);
}
/**
* Prepares an audio packet for dispatch.
*
* @param buffer - The Opus packet to prepare
*/
public prepareAudioPacket(buffer: Buffer) {
const state = this.state;
if (state.status !== VoiceConnectionStatus.Ready) return;
return state.networking.prepareAudioPacket(buffer);
}
/**
* Dispatches the previously prepared audio packet (if any)
*/
public dispatchAudio() {
const state = this.state;
if (state.status !== VoiceConnectionStatus.Ready) return;
return state.networking.dispatchAudio();
}
/**
* Prepares an audio packet and dispatches it immediately.
*
* @param buffer - The Opus packet to play
*/
public playOpusPacket(buffer: Buffer) {
const state = this.state;
if (state.status !== VoiceConnectionStatus.Ready) return;
state.networking.prepareAudioPacket(buffer);
return state.networking.dispatchAudio();
}
/**
* Destroys the VoiceConnection, preventing it from connecting to voice again.
* This method should be called when you no longer require the VoiceConnection to
* prevent memory leaks.
*
* @param adapterAvailable - Whether the adapter can be used
*/
public destroy(adapterAvailable = true) {
if (this.state.status === VoiceConnectionStatus.Destroyed) {
throw new Error('Cannot destroy VoiceConnection - it has already been destroyed');
}
if (getVoiceConnection(this.joinConfig.guildId) === this) {
untrackVoiceConnection(this);
}
if (adapterAvailable) {
this.state.adapter.sendPayload(createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null }));
}
this.state = {
status: VoiceConnectionStatus.Destroyed,
};
}
/**
* Disconnects the VoiceConnection, allowing the possibility of rejoining later on.
*
* @returns `true` if the connection was successfully disconnected
*/
public disconnect() {
if (
this.state.status === VoiceConnectionStatus.Destroyed ||
this.state.status === VoiceConnectionStatus.Signalling
) {
return false;
}
this.joinConfig.channelId = null;
if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
this.state = {
adapter: this.state.adapter,
subscription: this.state.subscription,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
};
return false;
}
this.state = {
adapter: this.state.adapter,
reason: VoiceConnectionDisconnectReason.Manual,
status: VoiceConnectionStatus.Disconnected,
};
return true;
}
/**
* Attempts to rejoin (better explanation soon:tm:)
*
* @remarks
* Calling this method successfully will automatically increment the `rejoinAttempts` counter,
* which you can use to inform whether or not you'd like to keep attempting to reconnect your
* voice connection.
*
* A state transition from Disconnected to Signalling will be observed when this is called.
*/
public rejoin(joinConfig?: Omit<JoinConfig, 'guildId' | 'group'>) {
if (this.state.status === VoiceConnectionStatus.Destroyed) {
return false;
}
const notReady = this.state.status !== VoiceConnectionStatus.Ready;
if (notReady) this.rejoinAttempts++;
Object.assign(this.joinConfig, joinConfig);
if (this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) {
if (notReady) {
this.state = {
...this.state,
status: VoiceConnectionStatus.Signalling,
};
}
return true;
}
this.state = {
adapter: this.state.adapter,
subscription: this.state.subscription,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
};
return false;
}
/**
* Updates the speaking status of the voice connection. This is used when audio players are done playing audio,
* and need to signal that the connection is no longer playing audio.
*
* @param enabled - Whether or not to show as speaking
*/
public setSpeaking(enabled: boolean) {
if (this.state.status !== VoiceConnectionStatus.Ready) return false;
return this.state.networking.setSpeaking(enabled);
}
/**
* Subscribes to an audio player, allowing the player to play audio on this voice connection.
*
* @param player - The audio player to subscribe to
*
* @returns The created subscription
*/
public subscribe(player: AudioPlayer) {
if (this.state.status === VoiceConnectionStatus.Destroyed) return;
// eslint-disable-next-line @typescript-eslint/dot-notation
const subscription = player['subscribe'](this);
this.state = {
...this.state,
subscription,
};
return subscription;
}
/**
* The latest ping (in milliseconds) for the WebSocket connection and audio playback for this voice
* connection, if this data is available.
*
* @remarks
* For this data to be available, the VoiceConnection must be in the Ready state, and its underlying
* WebSocket connection and UDP socket must have had at least one ping-pong exchange.
*/
public get ping() {
if (
this.state.status === VoiceConnectionStatus.Ready &&
this.state.networking.state.code === NetworkingStatusCode.Ready
) {
return {
ws: this.state.networking.state.ws.ping,
udp: this.state.networking.state.udp.ping,
};
}
return {
ws: undefined,
udp: undefined,
};
}
/**
* Called when a subscription of this voice connection to an audio player is removed.
*
* @param subscription - The removed subscription
*/
// @ts-ignore
private onSubscriptionRemoved(subscription: PlayerSubscription) {
if (this.state.status !== VoiceConnectionStatus.Destroyed && this.state.subscription === subscription) {
this.state = {
...this.state,
subscription: undefined,
};
}
}
}
/**
* Creates a new voice connection.
*
* @param joinConfig - The data required to establish the voice connection
* @param options - The options to use when joining the voice channel
*/
export function createVoiceConnection(joinConfig: JoinConfig, options: CreateVoiceConnectionOptions) {
const payload = createJoinVoiceChannelPayload(joinConfig);
const existing = getVoiceConnection(joinConfig.guildId);
if (existing && existing.state.status !== VoiceConnectionStatus.Destroyed) {
if (existing.state.status === VoiceConnectionStatus.Disconnected) {
existing.rejoin({
channelId: joinConfig.channelId,
selfDeaf: joinConfig.selfDeaf,
selfMute: joinConfig.selfMute,
});
} else if (!existing.state.adapter.sendPayload(payload)) {
existing.state = {
...existing.state,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
};
}
return existing;
}
const voiceConnection = new VoiceConnection(joinConfig, options);
trackVoiceConnection(voiceConnection);
if (voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) {
if (!voiceConnection.state.adapter.sendPayload(payload)) {
voiceConnection.state = {
...voiceConnection.state,
status: VoiceConnectionStatus.Disconnected,
reason: VoiceConnectionDisconnectReason.AdapterUnavailable,
};
}
}
return voiceConnection;
}

View File

@@ -0,0 +1,627 @@
import { addAudioPlayer, deleteAudioPlayer } from '../DataStore';
import { Awaited, noop } from '../util/util';
import { VoiceConnection, VoiceConnectionStatus } from '../VoiceConnection';
import { AudioPlayerError } from './AudioPlayerError';
import type { AudioResource } from './AudioResource';
import { PlayerSubscription } from './PlayerSubscription';
import { TypedEmitter } from 'tiny-typed-emitter';
// The Opus "silent" frame
export const SILENCE_FRAME = Buffer.from([0xf8, 0xff, 0xfe]);
/**
* Describes the behavior of the player when an audio packet is played but there are no available
* voice connections to play to.
*/
export enum NoSubscriberBehavior {
/**
* Pauses playing the stream until a voice connection becomes available.
*/
Pause = 'pause',
/**
* Continues to play through the resource regardless.
*/
Play = 'play',
/**
* The player stops and enters the Idle state.
*/
Stop = 'stop',
}
export enum AudioPlayerStatus {
/**
* When there is currently no resource for the player to be playing.
*/
Idle = 'idle',
/**
* When the player is waiting for an audio resource to become readable before transitioning to Playing.
*/
Buffering = 'buffering',
/**
* When the player has been manually paused.
*/
Paused = 'paused',
/**
* When the player is actively playing an audio resource.
*/
Playing = 'playing',
/**
* When the player has paused itself. Only possible with the "pause" no subscriber behavior.
*/
AutoPaused = 'autopaused',
}
/**
* Options that can be passed when creating an audio player, used to specify its behavior.
*/
export interface CreateAudioPlayerOptions {
debug?: boolean;
behaviors?: {
noSubscriber?: NoSubscriberBehavior;
maxMissedFrames?: number;
};
}
/**
* The state that an AudioPlayer is in when it has no resource to play. This is the starting state.
*/
export interface AudioPlayerIdleState {
status: AudioPlayerStatus.Idle;
}
/**
* The state that an AudioPlayer is in when it is waiting for a resource to become readable. Once this
* happens, the AudioPlayer will enter the Playing state. If the resource ends/errors before this, then
* it will re-enter the Idle state.
*/
export interface AudioPlayerBufferingState {
status: AudioPlayerStatus.Buffering;
/**
* The resource that the AudioPlayer is waiting for
*/
resource: AudioResource;
onReadableCallback: () => void;
onFailureCallback: () => void;
onStreamError: (error: Error) => void;
}
/**
* The state that an AudioPlayer is in when it is actively playing an AudioResource. When playback ends,
* it will enter the Idle state.
*/
export interface AudioPlayerPlayingState {
status: AudioPlayerStatus.Playing;
/**
* The number of consecutive times that the audio resource has been unable to provide an Opus frame.
*/
missedFrames: number;
/**
* The playback duration in milliseconds of the current audio resource. This includes filler silence packets
* that have been played when the resource was buffering.
*/
playbackDuration: number;
/**
* The resource that is being played.
*/
resource: AudioResource;
onStreamError: (error: Error) => void;
}
/**
* The state that an AudioPlayer is in when it has either been explicitly paused by the user, or done
* automatically by the AudioPlayer itself if there are no available subscribers.
*/
export interface AudioPlayerPausedState {
status: AudioPlayerStatus.Paused | AudioPlayerStatus.AutoPaused;
/**
* How many silence packets still need to be played to avoid audio interpolation due to the stream suddenly pausing.
*/
silencePacketsRemaining: number;
/**
* The playback duration in milliseconds of the current audio resource. This includes filler silence packets
* that have been played when the resource was buffering.
*/
playbackDuration: number;
/**
* The current resource of the audio player.
*/
resource: AudioResource;
onStreamError: (error: Error) => void;
}
/**
* The various states that the player can be in.
*/
export type AudioPlayerState =
| AudioPlayerIdleState
| AudioPlayerBufferingState
| AudioPlayerPlayingState
| AudioPlayerPausedState;
export type AudioPlayerEvents = {
error: (error: AudioPlayerError) => Awaited<void>;
debug: (message: string) => Awaited<void>;
stateChange: (oldState: AudioPlayerState, newState: AudioPlayerState) => Awaited<void>;
subscribe: (subscription: PlayerSubscription) => Awaited<void>;
unsubscribe: (subscription: PlayerSubscription) => Awaited<void>;
} & {
[status in AudioPlayerStatus]: (
oldState: AudioPlayerState,
newState: AudioPlayerState & { status: status },
) => Awaited<void>;
};
/**
* Used to play audio resources (i.e. tracks, streams) to voice connections.
*
* @remarks
* Audio players are designed to be re-used - even if a resource has finished playing, the player itself
* can still be used.
*
* The AudioPlayer drives the timing of playback, and therefore is unaffected by voice connections
* becoming unavailable. Its behavior in these scenarios can be configured.
*/
export class AudioPlayer extends TypedEmitter<AudioPlayerEvents> {
/**
* The state that the AudioPlayer is in.
*/
private _state: AudioPlayerState;
/**
* A list of VoiceConnections that are registered to this AudioPlayer. The player will attempt to play audio
* to the streams in this list.
*/
private readonly subscribers: PlayerSubscription[] = [];
/**
* The behavior that the player should follow when it enters certain situations.
*/
private readonly behaviors: {
noSubscriber: NoSubscriberBehavior;
maxMissedFrames: number;
};
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new AudioPlayer.
*/
public constructor(options: CreateAudioPlayerOptions = {}) {
super();
this._state = { status: AudioPlayerStatus.Idle };
this.behaviors = {
noSubscriber: NoSubscriberBehavior.Pause,
maxMissedFrames: 5,
...options.behaviors,
};
this.debug = options.debug === false ? null : (message: string) => this.emit('debug', message);
}
/**
* A list of subscribed voice connections that can currently receive audio to play.
*/
public get playable() {
return this.subscribers
.filter(({ connection }) => connection.state.status === VoiceConnectionStatus.Ready)
.map(({ connection }) => connection);
}
/**
* Subscribes a VoiceConnection to the audio player's play list. If the VoiceConnection is already subscribed,
* then the existing subscription is used.
*
* @remarks
* This method should not be directly called. Instead, use VoiceConnection#subscribe.
*
* @param connection - The connection to subscribe
*
* @returns The new subscription if the voice connection is not yet subscribed, otherwise the existing subscription
*/
// @ts-ignore
private subscribe(connection: VoiceConnection) {
const existingSubscription = this.subscribers.find((subscription) => subscription.connection === connection);
if (!existingSubscription) {
const subscription = new PlayerSubscription(connection, this);
this.subscribers.push(subscription);
setImmediate(() => this.emit('subscribe', subscription));
return subscription;
}
return existingSubscription;
}
/**
* Unsubscribes a subscription - i.e. removes a voice connection from the play list of the audio player.
*
* @remarks
* This method should not be directly called. Instead, use PlayerSubscription#unsubscribe.
*
* @param subscription - The subscription to remove
*
* @returns Whether or not the subscription existed on the player and was removed
*/
// @ts-ignore
private unsubscribe(subscription: PlayerSubscription) {
const index = this.subscribers.indexOf(subscription);
const exists = index !== -1;
if (exists) {
this.subscribers.splice(index, 1);
subscription.connection.setSpeaking(false);
this.emit('unsubscribe', subscription);
}
return exists;
}
/**
* The state that the player is in.
*/
public get state() {
return this._state;
}
/**
* Sets a new state for the player, performing clean-up operations where necessary.
*/
public set state(newState: AudioPlayerState) {
const oldState = this._state;
const newResource = Reflect.get(newState, 'resource') as AudioResource | undefined;
if (oldState.status !== AudioPlayerStatus.Idle && oldState.resource !== newResource) {
oldState.resource.playStream.on('error', noop);
oldState.resource.playStream.off('error', oldState.onStreamError);
oldState.resource.audioPlayer = undefined;
oldState.resource.playStream.destroy();
oldState.resource.playStream.read(); // required to ensure buffered data is drained, prevents memory leak
}
// When leaving the Buffering state (or buffering a new resource), then remove the event listeners from it
if (
oldState.status === AudioPlayerStatus.Buffering &&
(newState.status !== AudioPlayerStatus.Buffering || newState.resource !== oldState.resource)
) {
oldState.resource.playStream.off('end', oldState.onFailureCallback);
oldState.resource.playStream.off('close', oldState.onFailureCallback);
oldState.resource.playStream.off('finish', oldState.onFailureCallback);
oldState.resource.playStream.off('readable', oldState.onReadableCallback);
}
// transitioning into an idle should ensure that connections stop speaking
if (newState.status === AudioPlayerStatus.Idle) {
this._signalStopSpeaking();
deleteAudioPlayer(this);
}
// attach to the global audio player timer
if (newResource) {
addAudioPlayer(this);
}
// playing -> playing state changes should still transition if a resource changed (seems like it would be useful!)
const didChangeResources =
oldState.status !== AudioPlayerStatus.Idle &&
newState.status === AudioPlayerStatus.Playing &&
oldState.resource !== newState.resource;
this._state = newState;
this.emit('stateChange', oldState, this._state);
if (oldState.status !== newState.status || didChangeResources) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.emit(newState.status, oldState, this._state as any);
}
this.debug?.(`state change:\nfrom ${stringifyState(oldState)}\nto ${stringifyState(newState)}`);
}
/**
* Plays a new resource on the player. If the player is already playing a resource, the existing resource is destroyed
* (it cannot be reused, even in another player) and is replaced with the new resource.
*
* @remarks
* The player will transition to the Playing state once playback begins, and will return to the Idle state once
* playback is ended.
*
* If the player was previously playing a resource and this method is called, the player will not transition to the
* Idle state during the swap over.
*
* @param resource - The resource to play
*
* @throws Will throw if attempting to play an audio resource that has already ended, or is being played by another player
*/
public play<T>(resource: AudioResource<T>) {
if (resource.ended) {
throw new Error('Cannot play a resource that has already ended.');
}
if (resource.audioPlayer) {
if (resource.audioPlayer === this) {
return;
}
throw new Error('Resource is already being played by another audio player.');
}
resource.audioPlayer = this;
// Attach error listeners to the stream that will propagate the error and then return to the Idle
// state if the resource is still being used.
const onStreamError = (error: Error) => {
if (this.state.status !== AudioPlayerStatus.Idle) {
/**
* Emitted when there is an error emitted from the audio resource played by the audio player
*
* @event AudioPlayer#error
* @type {AudioPlayerError}
*/
this.emit('error', new AudioPlayerError(error, this.state.resource));
}
if (this.state.status !== AudioPlayerStatus.Idle && this.state.resource === resource) {
this.state = {
status: AudioPlayerStatus.Idle,
};
}
};
resource.playStream.once('error', onStreamError);
if (resource.started) {
this.state = {
status: AudioPlayerStatus.Playing,
missedFrames: 0,
playbackDuration: 0,
resource,
onStreamError,
};
} else {
const onReadableCallback = () => {
if (this.state.status === AudioPlayerStatus.Buffering && this.state.resource === resource) {
this.state = {
status: AudioPlayerStatus.Playing,
missedFrames: 0,
playbackDuration: 0,
resource,
onStreamError,
};
}
};
const onFailureCallback = () => {
if (this.state.status === AudioPlayerStatus.Buffering && this.state.resource === resource) {
this.state = {
status: AudioPlayerStatus.Idle,
};
}
};
resource.playStream.once('readable', onReadableCallback);
resource.playStream.once('end', onFailureCallback);
resource.playStream.once('close', onFailureCallback);
resource.playStream.once('finish', onFailureCallback);
this.state = {
status: AudioPlayerStatus.Buffering,
resource,
onReadableCallback,
onFailureCallback,
onStreamError,
};
}
}
/**
* Pauses playback of the current resource, if any.
*
* @param interpolateSilence - If true, the player will play 5 packets of silence after pausing to prevent audio glitches
*
* @returns `true` if the player was successfully paused, otherwise `false`
*/
public pause(interpolateSilence = true) {
if (this.state.status !== AudioPlayerStatus.Playing) return false;
this.state = {
...this.state,
status: AudioPlayerStatus.Paused,
silencePacketsRemaining: interpolateSilence ? 5 : 0,
};
return true;
}
/**
* Unpauses playback of the current resource, if any.
*
* @returns `true` if the player was successfully unpaused, otherwise `false`
*/
public unpause() {
if (this.state.status !== AudioPlayerStatus.Paused) return false;
this.state = {
...this.state,
status: AudioPlayerStatus.Playing,
missedFrames: 0,
};
return true;
}
/**
* Stops playback of the current resource and destroys the resource. The player will either transition to the Idle state,
* or remain in its current state until the silence padding frames of the resource have been played.
*
* @param force - If true, will force the player to enter the Idle state even if the resource has silence padding frames
*
* @returns `true` if the player will come to a stop, otherwise `false`
*/
public stop(force = false) {
if (this.state.status === AudioPlayerStatus.Idle) return false;
if (force || this.state.resource.silencePaddingFrames === 0) {
this.state = {
status: AudioPlayerStatus.Idle,
};
} else if (this.state.resource.silenceRemaining === -1) {
this.state.resource.silenceRemaining = this.state.resource.silencePaddingFrames;
}
return true;
}
/**
* Checks whether the underlying resource (if any) is playable (readable)
*
* @returns `true` if the resource is playable, otherwise `false`
*/
public checkPlayable() {
const state = this._state;
if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return false;
// If the stream has been destroyed or is no longer readable, then transition to the Idle state.
if (!state.resource.readable) {
this.state = {
status: AudioPlayerStatus.Idle,
};
return false;
}
return true;
}
/**
* Called roughly every 20ms by the global audio player timer. Dispatches any audio packets that are buffered
* by the active connections of this audio player.
*/
// @ts-ignore
private _stepDispatch() {
const state = this._state;
// Guard against the Idle state
if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return;
// Dispatch any audio packets that were prepared in the previous cycle
this.playable.forEach((connection) => connection.dispatchAudio());
}
/**
* Called roughly every 20ms by the global audio player timer. Attempts to read an audio packet from the
* underlying resource of the stream, and then has all the active connections of the audio player prepare it
* (encrypt it, append header data) so that it is ready to play at the start of the next cycle.
*/
// @ts-ignore
private _stepPrepare() {
const state = this._state;
// Guard against the Idle state
if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return;
// List of connections that can receive the packet
const playable = this.playable;
/* If the player was previously in the AutoPaused state, check to see whether there are newly available
connections, allowing us to transition out of the AutoPaused state back into the Playing state */
if (state.status === AudioPlayerStatus.AutoPaused && playable.length > 0) {
this.state = {
...state,
status: AudioPlayerStatus.Playing,
missedFrames: 0,
};
}
/* If the player is (auto)paused, check to see whether silence packets should be played and
set a timeout to begin the next cycle, ending the current cycle here. */
if (state.status === AudioPlayerStatus.Paused || state.status === AudioPlayerStatus.AutoPaused) {
if (state.silencePacketsRemaining > 0) {
state.silencePacketsRemaining--;
this._preparePacket(SILENCE_FRAME, playable, state);
if (state.silencePacketsRemaining === 0) {
this._signalStopSpeaking();
}
}
return;
}
// If there are no available connections in this cycle, observe the configured "no subscriber" behavior.
if (playable.length === 0) {
if (this.behaviors.noSubscriber === NoSubscriberBehavior.Pause) {
this.state = {
...state,
status: AudioPlayerStatus.AutoPaused,
silencePacketsRemaining: 5,
};
return;
} else if (this.behaviors.noSubscriber === NoSubscriberBehavior.Stop) {
this.stop(true);
}
}
/**
* Attempt to read an Opus packet from the resource. If there isn't an available packet,
* play a silence packet. If there are 5 consecutive cycles with failed reads, then the
* playback will end.
*/
const packet: Buffer | null = state.resource.read();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (state.status === AudioPlayerStatus.Playing) {
if (packet) {
this._preparePacket(packet, playable, state);
state.missedFrames = 0;
} else {
this._preparePacket(SILENCE_FRAME, playable, state);
state.missedFrames++;
if (state.missedFrames >= this.behaviors.maxMissedFrames) {
this.stop();
}
}
}
}
/**
* Signals to all the subscribed connections that they should send a packet to Discord indicating
* they are no longer speaking. Called once playback of a resource ends.
*/
private _signalStopSpeaking() {
return this.subscribers.forEach(({ connection }) => connection.setSpeaking(false));
}
/**
* Instructs the given connections to each prepare this packet to be played at the start of the
* next cycle.
*
* @param packet - The Opus packet to be prepared by each receiver
* @param receivers - The connections that should play this packet
*/
private _preparePacket(
packet: Buffer,
receivers: VoiceConnection[],
state: AudioPlayerPlayingState | AudioPlayerPausedState,
) {
state.playbackDuration += 20;
receivers.forEach((connection) => connection.prepareAudioPacket(packet));
}
}
/**
* Stringifies an AudioPlayerState instance.
*
* @param state - The state to stringify
*/
function stringifyState(state: AudioPlayerState) {
return JSON.stringify({
...state,
resource: Reflect.has(state, 'resource'),
stepTimeout: Reflect.has(state, 'stepTimeout'),
});
}
/**
* Creates a new AudioPlayer to be used.
*/
export function createAudioPlayer(options?: CreateAudioPlayerOptions) {
return new AudioPlayer(options);
}

View File

@@ -0,0 +1,18 @@
import type { AudioResource } from './AudioResource';
/**
* An error emitted by an AudioPlayer. Contains an attached resource to aid with
* debugging and identifying where the error came from.
*/
export class AudioPlayerError extends Error {
/**
* The resource associated with the audio player at the time the error was thrown.
*/
public readonly resource: AudioResource;
public constructor(error: Error, resource: AudioResource) {
super(error.message);
this.resource = resource;
this.name = error.name;
this.stack = error.stack;
}
}

View File

@@ -0,0 +1,285 @@
import { Edge, findPipeline, StreamType, TransformerType } from './TransformerGraph';
import { pipeline, Readable } from 'node:stream';
import { noop } from '../util/util';
import prism from 'prism-media';
import { AudioPlayer, SILENCE_FRAME } from './AudioPlayer';
/**
* Options that are set when creating a new audio resource.
*
* @template T - the type for the metadata (if any) of the audio resource
*/
export interface CreateAudioResourceOptions<T> {
/**
* The type of the input stream. Defaults to `StreamType.Arbitrary`.
*/
inputType?: StreamType;
/**
* Optional metadata that can be attached to the resource (e.g. track title, random id).
* This is useful for identification purposes when the resource is passed around in events.
* See {@link AudioResource.metadata}
*/
metadata?: T;
/**
* Whether or not inline volume should be enabled. If enabled, you will be able to change the volume
* of the stream on-the-fly. However, this also increases the performance cost of playback. Defaults to `false`.
*/
inlineVolume?: boolean;
/**
* The number of silence frames to append to the end of the resource's audio stream, to prevent interpolation glitches.
* Defaults to 5.
*/
silencePaddingFrames?: number;
}
/**
* Represents an audio resource that can be played by an audio player.
*
* @template T - the type for the metadata (if any) of the audio resource
*/
export class AudioResource<T = unknown> {
/**
* An object-mode Readable stream that emits Opus packets. This is what is played by audio players.
*/
public readonly playStream: Readable;
/**
* The pipeline used to convert the input stream into a playable format. For example, this may
* contain an FFmpeg component for arbitrary inputs, and it may contain a VolumeTransformer component
* for resources with inline volume transformation enabled.
*/
public readonly edges: readonly Edge[];
/**
* Optional metadata that can be used to identify the resource.
*/
public metadata: T;
/**
* If the resource was created with inline volume transformation enabled, then this will be a
* prism-media VolumeTransformer. You can use this to alter the volume of the stream.
*/
public readonly volume?: prism.VolumeTransformer;
/**
* If using an Opus encoder to create this audio resource, then this will be a prism-media opus.Encoder.
* You can use this to control settings such as bitrate, FEC, PLP.
*/
public readonly encoder?: prism.opus.Encoder;
/**
* The audio player that the resource is subscribed to, if any.
*/
public audioPlayer?: AudioPlayer;
/**
* The playback duration of this audio resource, given in milliseconds.
*/
public playbackDuration = 0;
/**
* Whether or not the stream for this resource has started (data has become readable)
*/
public started = false;
/**
* The number of silence frames to append to the end of the resource's audio stream, to prevent interpolation glitches.
*/
public readonly silencePaddingFrames: number;
/**
* The number of remaining silence frames to play. If -1, the frames have not yet started playing.
*/
public silenceRemaining = -1;
public constructor(edges: readonly Edge[], streams: readonly Readable[], metadata: T, silencePaddingFrames: number) {
this.edges = edges;
this.playStream = streams.length > 1 ? (pipeline(streams, noop) as any as Readable) : streams[0];
this.metadata = metadata;
this.silencePaddingFrames = silencePaddingFrames;
for (const stream of streams) {
if (stream instanceof prism.VolumeTransformer) {
this.volume = stream;
} else if (stream instanceof prism.opus.Encoder) {
this.encoder = stream;
}
}
this.playStream.once('readable', () => (this.started = true));
}
/**
* Whether this resource is readable. If the underlying resource is no longer readable, this will still return true
* while there are silence padding frames left to play.
*/
public get readable() {
if (this.silenceRemaining === 0) return false;
const real = this.playStream.readable;
if (!real) {
if (this.silenceRemaining === -1) this.silenceRemaining = this.silencePaddingFrames;
return this.silenceRemaining !== 0;
}
return real;
}
/**
* Whether this resource has ended or not.
*/
public get ended() {
return this.playStream.readableEnded || this.playStream.destroyed || this.silenceRemaining === 0;
}
/**
* Attempts to read an Opus packet from the audio resource. If a packet is available, the playbackDuration
* is incremented.
*
* @remarks
* It is advisable to check that the playStream is readable before calling this method. While no runtime
* errors will be thrown, you should check that the resource is still available before attempting to
* read from it.
*
* @internal
*/
public read(): Buffer | null {
if (this.silenceRemaining === 0) {
return null;
} else if (this.silenceRemaining > 0) {
this.silenceRemaining--;
return SILENCE_FRAME;
}
const packet: Buffer | null = this.playStream.read();
if (packet) {
this.playbackDuration += 20;
}
return packet;
}
}
/**
* Ensures that a path contains at least one volume transforming component.
*
* @param path - The path to validate constraints on
*/
export const VOLUME_CONSTRAINT = (path: Edge[]) => path.some((edge) => edge.type === TransformerType.InlineVolume);
export const NO_CONSTRAINT = () => true;
/**
* Tries to infer the type of a stream to aid with transcoder pipelining.
*
* @param stream - The stream to infer the type of
*/
export function inferStreamType(stream: Readable): {
streamType: StreamType;
hasVolume: boolean;
} {
if (stream instanceof prism.opus.Encoder) {
return { streamType: StreamType.Opus, hasVolume: false };
} else if (stream instanceof prism.opus.Decoder) {
return { streamType: StreamType.Raw, hasVolume: false };
} else if (stream instanceof prism.VolumeTransformer) {
return { streamType: StreamType.Raw, hasVolume: true };
} else if (stream instanceof prism.opus.OggDemuxer) {
return { streamType: StreamType.Opus, hasVolume: false };
} else if (stream instanceof prism.opus.WebmDemuxer) {
return { streamType: StreamType.Opus, hasVolume: false };
}
return { streamType: StreamType.Arbitrary, hasVolume: false };
}
/**
* Creates an audio resource that can be played by audio players.
*
* @remarks
* If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used.
*
* If the input is not in the correct format, then a pipeline of transcoders and transformers will be created
* to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg,
* Opus transcoders, and Ogg/WebM demuxers.
*
* @param input - The resource to play
* @param options - Configurable options for creating the resource
*
* @template T - the type for the metadata (if any) of the audio resource
*/
export function createAudioResource<T>(
input: string | Readable,
options: CreateAudioResourceOptions<T> &
Pick<
T extends null | undefined ? CreateAudioResourceOptions<T> : Required<CreateAudioResourceOptions<T>>,
'metadata'
>,
): AudioResource<T extends null | undefined ? null : T>;
/**
* Creates an audio resource that can be played by audio players.
*
* @remarks
* If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used.
*
* If the input is not in the correct format, then a pipeline of transcoders and transformers will be created
* to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg,
* Opus transcoders, and Ogg/WebM demuxers.
*
* @param input - The resource to play
* @param options - Configurable options for creating the resource
*
* @template T - the type for the metadata (if any) of the audio resource
*/
export function createAudioResource<T extends null | undefined>(
input: string | Readable,
options?: Omit<CreateAudioResourceOptions<T>, 'metadata'>,
): AudioResource<null>;
/**
* Creates an audio resource that can be played by audio players.
*
* @remarks
* If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used.
*
* If the input is not in the correct format, then a pipeline of transcoders and transformers will be created
* to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg,
* Opus transcoders, and Ogg/WebM demuxers.
*
* @param input - The resource to play
* @param options - Configurable options for creating the resource
*
* @template T - the type for the metadata (if any) of the audio resource
*/
export function createAudioResource<T>(
input: string | Readable,
options: CreateAudioResourceOptions<T> = {},
): AudioResource<T> {
let inputType = options.inputType;
let needsInlineVolume = Boolean(options.inlineVolume);
// string inputs can only be used with FFmpeg
if (typeof input === 'string') {
inputType = StreamType.Arbitrary;
} else if (typeof inputType === 'undefined') {
const analysis = inferStreamType(input);
inputType = analysis.streamType;
needsInlineVolume = needsInlineVolume && !analysis.hasVolume;
}
const transformerPipeline = findPipeline(inputType, needsInlineVolume ? VOLUME_CONSTRAINT : NO_CONSTRAINT);
if (transformerPipeline.length === 0) {
if (typeof input === 'string') throw new Error(`Invalid pipeline constructed for string resource '${input}'`);
// No adjustments required
return new AudioResource<T>([], [input], (options.metadata ?? null) as T, options.silencePaddingFrames ?? 5);
}
const streams = transformerPipeline.map((edge) => edge.transformer(input));
if (typeof input !== 'string') streams.unshift(input);
return new AudioResource<T>(
transformerPipeline,
streams,
(options.metadata ?? null) as T,
options.silencePaddingFrames ?? 5,
);
}

View File

@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/dot-notation */
import type { VoiceConnection } from '../VoiceConnection';
import type { AudioPlayer } from './AudioPlayer';
/**
* Represents a subscription of a voice connection to an audio player, allowing
* the audio player to play audio on the voice connection.
*/
export class PlayerSubscription {
/**
* The voice connection of this subscription.
*/
public readonly connection: VoiceConnection;
/**
* The audio player of this subscription.
*/
public readonly player: AudioPlayer;
public constructor(connection: VoiceConnection, player: AudioPlayer) {
this.connection = connection;
this.player = player;
}
/**
* Unsubscribes the connection from the audio player, meaning that the
* audio player cannot stream audio to it until a new subscription is made.
*/
public unsubscribe() {
this.connection['onSubscriptionRemoved'](this);
this.player['unsubscribe'](this);
}
}

View File

@@ -0,0 +1,264 @@
import type { Readable } from 'node:stream';
import prism from 'prism-media';
/**
* This module creates a Transformer Graph to figure out what the most efficient way
* of transforming the input stream into something playable would be.
*/
const FFMPEG_PCM_ARGUMENTS = ['-analyzeduration', '0', '-loglevel', '0', '-f', 's16le', '-ar', '48000', '-ac', '2'];
const FFMPEG_OPUS_ARGUMENTS = [
'-analyzeduration',
'0',
'-loglevel',
'0',
'-acodec',
'libopus',
'-f',
'opus',
'-ar',
'48000',
'-ac',
'2',
];
/**
* The different types of stream that can exist within the pipeline.
*
* @remarks
* - `Arbitrary` - the type of the stream at this point is unknown.
* - `Raw` - the stream at this point is s16le PCM.
* - `OggOpus` - the stream at this point is Opus audio encoded in an Ogg wrapper.
* - `WebmOpus` - the stream at this point is Opus audio encoded in a WebM wrapper.
* - `Opus` - the stream at this point is Opus audio, and the stream is in object-mode. This is ready to play.
*/
export enum StreamType {
Arbitrary = 'arbitrary',
Raw = 'raw',
OggOpus = 'ogg/opus',
WebmOpus = 'webm/opus',
Opus = 'opus',
}
/**
* The different types of transformers that can exist within the pipeline.
*/
export enum TransformerType {
FFmpegPCM = 'ffmpeg pcm',
FFmpegOgg = 'ffmpeg ogg',
OpusEncoder = 'opus encoder',
OpusDecoder = 'opus decoder',
OggOpusDemuxer = 'ogg/opus demuxer',
WebmOpusDemuxer = 'webm/opus demuxer',
InlineVolume = 'volume transformer',
}
/**
* Represents a pathway from one stream type to another using a transformer.
*/
export interface Edge {
from: Node;
to: Node;
cost: number;
transformer: (input: string | Readable) => Readable;
type: TransformerType;
}
/**
* Represents a type of stream within the graph, e.g. an Opus stream, or a stream of raw audio.
*/
export class Node {
/**
* The outbound edges from this node.
*/
public readonly edges: Edge[] = [];
/**
* The type of stream for this node.
*/
public readonly type: StreamType;
public constructor(type: StreamType) {
this.type = type;
}
/**
* Creates an outbound edge from this node.
*
* @param edge - The edge to create
*/
public addEdge(edge: Omit<Edge, 'from'>) {
this.edges.push({ ...edge, from: this });
}
}
// Create a node for each stream type
const NODES = new Map<StreamType, Node>();
for (const streamType of Object.values(StreamType)) {
NODES.set(streamType, new Node(streamType));
}
/**
* Gets a node from its stream type.
*
* @param type - The stream type of the target node
*/
export function getNode(type: StreamType) {
const node = NODES.get(type);
if (!node) throw new Error(`Node type '${type}' does not exist!`);
return node;
}
getNode(StreamType.Raw).addEdge({
type: TransformerType.OpusEncoder,
to: getNode(StreamType.Opus),
cost: 1.5,
transformer: () => new prism.opus.Encoder({ rate: 48000, channels: 2, frameSize: 960 }),
});
getNode(StreamType.Opus).addEdge({
type: TransformerType.OpusDecoder,
to: getNode(StreamType.Raw),
cost: 1.5,
transformer: () => new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }),
});
getNode(StreamType.OggOpus).addEdge({
type: TransformerType.OggOpusDemuxer,
to: getNode(StreamType.Opus),
cost: 1,
transformer: () => new prism.opus.OggDemuxer(),
});
getNode(StreamType.WebmOpus).addEdge({
type: TransformerType.WebmOpusDemuxer,
to: getNode(StreamType.Opus),
cost: 1,
transformer: () => new prism.opus.WebmDemuxer(),
});
const FFMPEG_PCM_EDGE: Omit<Edge, 'from'> = {
type: TransformerType.FFmpegPCM,
to: getNode(StreamType.Raw),
cost: 2,
transformer: (input) =>
new prism.FFmpeg({
args: typeof input === 'string' ? ['-i', input, ...FFMPEG_PCM_ARGUMENTS] : FFMPEG_PCM_ARGUMENTS,
}),
};
getNode(StreamType.Arbitrary).addEdge(FFMPEG_PCM_EDGE);
getNode(StreamType.OggOpus).addEdge(FFMPEG_PCM_EDGE);
getNode(StreamType.WebmOpus).addEdge(FFMPEG_PCM_EDGE);
getNode(StreamType.Raw).addEdge({
type: TransformerType.InlineVolume,
to: getNode(StreamType.Raw),
cost: 0.5,
transformer: () => new prism.VolumeTransformer({ type: 's16le' }),
});
// Try to enable FFmpeg Ogg optimizations
function canEnableFFmpegOptimizations(): boolean {
try {
return prism.FFmpeg.getInfo().output.includes('--enable-libopus');
} catch {}
return false;
}
if (canEnableFFmpegOptimizations()) {
const FFMPEG_OGG_EDGE: Omit<Edge, 'from'> = {
type: TransformerType.FFmpegOgg,
to: getNode(StreamType.OggOpus),
cost: 2,
transformer: (input) =>
new prism.FFmpeg({
args: typeof input === 'string' ? ['-i', input, ...FFMPEG_OPUS_ARGUMENTS] : FFMPEG_OPUS_ARGUMENTS,
}),
};
getNode(StreamType.Arbitrary).addEdge(FFMPEG_OGG_EDGE);
// Include Ogg and WebM as well in case they have different sampling rates or are mono instead of stereo
// at the moment, this will not do anything. However, if/when detection for correct Opus headers is
// implemented, this will help inform the voice engine that it is able to transcode the audio.
getNode(StreamType.OggOpus).addEdge(FFMPEG_OGG_EDGE);
getNode(StreamType.WebmOpus).addEdge(FFMPEG_OGG_EDGE);
}
/**
* Represents a step in the path from node A to node B.
*/
interface Step {
/**
* The next step.
*/
next?: Step;
/**
* The cost of the steps after this step.
*/
cost: number;
/**
* The edge associated with this step.
*/
edge?: Edge;
}
/**
* Finds the shortest cost path from node A to node B.
*
* @param from - The start node
* @param constraints - Extra validation for a potential solution. Takes a path, returns true if the path is valid
* @param goal - The target node
* @param path - The running path
* @param depth - The number of remaining recursions
*/
function findPath(
from: Node,
constraints: (path: Edge[]) => boolean,
goal = getNode(StreamType.Opus),
path: Edge[] = [],
depth = 5,
): Step {
if (from === goal && constraints(path)) {
return { cost: 0 };
} else if (depth === 0) {
return { cost: Infinity };
}
let currentBest: Step | undefined = undefined;
for (const edge of from.edges) {
if (currentBest && edge.cost > currentBest.cost) continue;
const next = findPath(edge.to, constraints, goal, [...path, edge], depth - 1);
const cost = edge.cost + next.cost;
if (!currentBest || cost < currentBest.cost) {
currentBest = { cost, edge, next };
}
}
return currentBest ?? { cost: Infinity };
}
/**
* Takes the solution from findPath and assembles it into a list of edges.
*
* @param step - The first step of the path
*/
function constructPipeline(step: Step) {
const edges = [];
let current: Step | undefined = step;
while (current?.edge) {
edges.push(current.edge);
current = current.next;
}
return edges;
}
/**
* Finds the lowest-cost pipeline to convert the input stream type into an Opus stream.
*
* @param from - The stream type to start from
* @param constraint - Extra constraints that may be imposed on potential solution
*/
export function findPipeline(from: StreamType, constraint: (path: Edge[]) => boolean) {
return constructPipeline(findPath(getNode(from), constraint));
}

View File

@@ -0,0 +1,390 @@
/* eslint-disable @typescript-eslint/dot-notation */
import { AudioResource } from '../../audio/AudioResource';
import { createAudioPlayer, AudioPlayerStatus, AudioPlayer, SILENCE_FRAME } from '../AudioPlayer';
import { Readable } from 'node:stream';
import { addAudioPlayer, deleteAudioPlayer } from '../../DataStore';
import { NoSubscriberBehavior } from '../..';
import { VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
import { once } from 'node:events';
import { AudioPlayerError } from '../AudioPlayerError';
jest.mock('../../DataStore');
jest.mock('../../VoiceConnection');
jest.mock('../AudioPlayerError');
const addAudioPlayerMock = addAudioPlayer as unknown as jest.Mock<typeof addAudioPlayer>;
const deleteAudioPlayerMock = deleteAudioPlayer as unknown as jest.Mock<typeof deleteAudioPlayer>;
const AudioPlayerErrorMock = AudioPlayerError as unknown as jest.Mock<typeof AudioPlayerError>;
const VoiceConnectionMock = VoiceConnection as unknown as jest.Mock<VoiceConnection>;
function* silence() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
yield Buffer.from([0xf8, 0xff, 0xfe]);
}
}
function createVoiceConnectionMock() {
const connection = new VoiceConnectionMock();
connection.state = {
status: VoiceConnectionStatus.Signalling,
adapter: {
sendPayload: jest.fn(),
destroy: jest.fn(),
},
};
connection.subscribe = jest.fn((player) => player['subscribe'](connection));
return connection;
}
function wait() {
return new Promise((resolve) => process.nextTick(resolve));
}
async function started(resource: AudioResource) {
while (!resource.started) {
await wait();
}
return resource;
}
let player: AudioPlayer | undefined;
beforeEach(() => {
AudioPlayerErrorMock.mockReset();
VoiceConnectionMock.mockReset();
addAudioPlayerMock.mockReset();
deleteAudioPlayerMock.mockReset();
});
afterEach(() => {
player?.stop(true);
});
describe('State transitions', () => {
test('Starts in Idle state', () => {
player = createAudioPlayer();
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(addAudioPlayerMock).toBeCalledTimes(0);
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
});
test('Playing resource with pausing and resuming', async () => {
// Call AudioResource constructor directly to avoid analysing pipeline for stream
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer();
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
// Pause and unpause should not affect the status of an Idle player
expect(player.pause()).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(player.unpause()).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(addAudioPlayerMock).toBeCalledTimes(0);
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
// Expect pause() to return true and transition to paused state
expect(player.pause()).toBe(true);
expect(player.state.status).toBe(AudioPlayerStatus.Paused);
// further calls to pause() should be unsuccessful
expect(player.pause()).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Paused);
// unpause() should transition back to Playing
expect(player.unpause()).toBe(true);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
// further calls to unpause() should be unsuccessful
expect(player.unpause()).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
// The audio player should not have been deleted throughout these changes
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
});
test('Playing to Stopping', async () => {
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer();
// stop() shouldn't do anything in Idle state
expect(player.stop(true)).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
expect(player.stop()).toBe(true);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(deleteAudioPlayerMock).toBeCalledTimes(0);
expect(resource.silenceRemaining).toBe(5);
});
test('Buffering to Playing', async () => {
const resource = new AudioResource([], [Readable.from(silence())], null, 5);
player = createAudioPlayer();
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Buffering);
await started(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toHaveBeenCalled();
expect(deleteAudioPlayerMock).not.toHaveBeenCalled();
});
describe('NoSubscriberBehavior transitions', () => {
test('NoSubscriberBehavior.Pause', async () => {
const connection = createVoiceConnectionMock();
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
throw new Error('Voice connection should have been Signalling');
}
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Pause } });
connection.subscribe(player);
player.play(resource);
expect(player.checkPlayable()).toBe(true);
player['_stepPrepare']();
expect(player.state.status).toBe(AudioPlayerStatus.AutoPaused);
connection.state = {
...connection.state,
status: VoiceConnectionStatus.Ready,
networking: null as any,
};
expect(player.checkPlayable()).toBe(true);
player['_stepPrepare']();
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
});
test('NoSubscriberBehavior.Play', async () => {
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
player.play(resource);
expect(player.checkPlayable()).toBe(true);
player['_stepPrepare']();
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
});
test('NoSubscriberBehavior.Stop', async () => {
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Stop } });
player.play(resource);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(player.checkPlayable()).toBe(true);
player['_stepPrepare']();
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(deleteAudioPlayerMock).toBeCalledTimes(1);
});
});
test('Normal playing state', async () => {
const connection = createVoiceConnectionMock();
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
throw new Error('Voice connection should have been Signalling');
}
connection.state = {
...connection.state,
status: VoiceConnectionStatus.Ready,
networking: null as any,
};
const buffer = Buffer.from([1, 2, 4, 8]);
const resource = await started(
new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
);
player = createAudioPlayer();
connection.subscribe(player);
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(player.checkPlayable()).toBe(true);
// Run through a few packet cycles
for (let i = 1; i <= 5; i++) {
player['_stepDispatch']();
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
await wait(); // Wait for the stream
player['_stepPrepare']();
expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(i);
expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(buffer);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
if (player.state.status === AudioPlayerStatus.Playing) {
expect(player.state.playbackDuration).toStrictEqual(i * 20);
}
}
// Expect silence to be played
player['_stepDispatch']();
expect(connection.dispatchAudio).toHaveBeenCalledTimes(6);
await wait();
player['_stepPrepare']();
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
typeof connection.prepareAudioPacket
>;
expect(prepareAudioPacket).toHaveBeenCalledTimes(6);
expect(prepareAudioPacket.mock.calls[5][0]).toEqual(silence().next().value);
player.stop(true);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(connection.setSpeaking).toBeCalledTimes(1);
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
});
test('stop() causes resource to use silence padding frames', async () => {
const connection = createVoiceConnectionMock();
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
throw new Error('Voice connection should have been Signalling');
}
connection.state = {
...connection.state,
status: VoiceConnectionStatus.Ready,
networking: null as any,
};
const buffer = Buffer.from([1, 2, 4, 8]);
const resource = await started(
new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
);
player = createAudioPlayer();
connection.subscribe(player);
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(player.checkPlayable()).toBe(true);
player.stop();
// Run through a few packet cycles
for (let i = 1; i <= 5; i++) {
player['_stepDispatch']();
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
await wait(); // Wait for the stream
player['_stepPrepare']();
expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(i);
expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(SILENCE_FRAME);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
if (player.state.status === AudioPlayerStatus.Playing) {
expect(player.state.playbackDuration).toStrictEqual(i * 20);
}
}
await wait();
expect(player.checkPlayable()).toBe(false);
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
typeof connection.prepareAudioPacket
>;
expect(prepareAudioPacket).toHaveBeenCalledTimes(5);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(connection.setSpeaking).toBeCalledTimes(1);
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
});
test('Plays silence 5 times for unreadable stream before quitting', async () => {
const connection = createVoiceConnectionMock();
if (connection.state.status !== VoiceConnectionStatus.Signalling) {
throw new Error('Voice connection should have been Signalling');
}
connection.state = {
...connection.state,
status: VoiceConnectionStatus.Ready,
networking: null as any,
};
const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
resource.playStream.read();
player = createAudioPlayer({ behaviors: { maxMissedFrames: 5 } });
connection.subscribe(player);
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
expect(addAudioPlayerMock).toBeCalledTimes(1);
expect(player.checkPlayable()).toBe(true);
const prepareAudioPacket = connection.prepareAudioPacket as unknown as jest.Mock<
typeof connection.prepareAudioPacket
>;
// Run through a few packet cycles
for (let i = 1; i <= 5; i++) {
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
if (player.state.status !== AudioPlayerStatus.Playing) throw new Error('Error');
expect(player.state.playbackDuration).toStrictEqual((i - 1) * 20);
expect(player.state.missedFrames).toBe(i - 1);
player['_stepDispatch']();
expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
player['_stepPrepare']();
expect(prepareAudioPacket).toHaveBeenCalledTimes(i);
expect(prepareAudioPacket.mock.calls[i - 1][0]).toEqual(silence().next().value);
}
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(connection.setSpeaking).toBeCalledTimes(1);
expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
expect(deleteAudioPlayerMock).toHaveBeenCalledTimes(1);
});
test('checkPlayable() transitions to Idle for unreadable stream', async () => {
const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
player = createAudioPlayer();
player.play(resource);
expect(player.checkPlayable()).toBe(true);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
for (let i = 0; i < 3; i++) {
resource.playStream.read();
await wait();
}
expect(resource.playStream.readableEnded).toBe(true);
expect(player.checkPlayable()).toBe(false);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
});
});
test('play() throws when playing a resource that has already ended', async () => {
const resource = await started(new AudioResource([], [Readable.from([1])], null, 5));
player = createAudioPlayer();
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
for (let i = 0; i < 3; i++) {
resource.playStream.read();
await wait();
}
expect(resource.playStream.readableEnded).toBe(true);
player.stop(true);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
expect(() => player.play(resource)).toThrow();
});
test('Propagates errors from streams', async () => {
const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
player = createAudioPlayer();
player.play(resource);
expect(player.state.status).toBe(AudioPlayerStatus.Playing);
const error = new Error('AudioPlayer test error');
process.nextTick(() => resource.playStream.emit('error', error));
const res = await once(player, 'error');
const playerError = res[0] as AudioPlayerError;
expect(playerError).toBeInstanceOf(AudioPlayerError);
expect(AudioPlayerErrorMock).toHaveBeenCalledWith(error, resource);
expect(player.state.status).toBe(AudioPlayerStatus.Idle);
});

View File

@@ -0,0 +1,124 @@
import { opus, VolumeTransformer } from 'prism-media';
import { PassThrough, Readable } from 'node:stream';
import { SILENCE_FRAME } from '../AudioPlayer';
import { AudioResource, createAudioResource, NO_CONSTRAINT, VOLUME_CONSTRAINT } from '../AudioResource';
import { Edge, findPipeline as _findPipeline, StreamType, TransformerType } from '../TransformerGraph';
jest.mock('prism-media');
jest.mock('../TransformerGraph');
function wait() {
return new Promise((resolve) => process.nextTick(resolve));
}
async function started(resource: AudioResource) {
while (!resource.started) {
await wait();
}
return resource;
}
const findPipeline = _findPipeline as unknown as jest.MockedFunction<typeof _findPipeline>;
beforeAll(() => {
findPipeline.mockImplementation((from: StreamType, constraint: (path: Edge[]) => boolean) => {
const base = [
{
cost: 1,
transformer: () => new PassThrough(),
type: TransformerType.FFmpegPCM,
},
];
if (constraint === VOLUME_CONSTRAINT) {
base.push({
cost: 1,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
transformer: () => new VolumeTransformer({} as any),
type: TransformerType.InlineVolume,
});
}
return base as any[];
});
});
beforeEach(() => {
findPipeline.mockClear();
});
describe('createAudioResource', () => {
test('Creates a resource from string path', () => {
const resource = createAudioResource('mypath.mp3');
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
});
test('Creates a resource from string path (volume)', () => {
const resource = createAudioResource('mypath.mp3', { inlineVolume: true });
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, VOLUME_CONSTRAINT);
expect(resource.volume).toBeInstanceOf(VolumeTransformer);
});
test('Only infers type if not explicitly given', () => {
const resource = createAudioResource(new opus.Encoder(), { inputType: StreamType.Arbitrary });
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
});
test('Infers from opus.Encoder', () => {
const resource = createAudioResource(new opus.Encoder(), { inlineVolume: true });
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, VOLUME_CONSTRAINT);
expect(resource.volume).toBeInstanceOf(VolumeTransformer);
expect(resource.encoder).toBeInstanceOf(opus.Encoder);
});
test('Infers from opus.OggDemuxer', () => {
const resource = createAudioResource(new opus.OggDemuxer());
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
expect(resource.encoder).toBeUndefined();
});
test('Infers from opus.WebmDemuxer', () => {
const resource = createAudioResource(new opus.WebmDemuxer());
expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
});
test('Infers from opus.Decoder', () => {
const resource = createAudioResource(new opus.Decoder());
expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
});
test('Infers from VolumeTransformer', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const stream = new VolumeTransformer({} as any);
const resource = createAudioResource(stream, { inlineVolume: true });
expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
expect(resource.volume).toBe(stream);
});
test('Falls back to Arbitrary for unknown stream type', () => {
const resource = createAudioResource(new PassThrough());
expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
expect(resource.volume).toBeUndefined();
});
test('Appends silence frames when ended', async () => {
const stream = Readable.from(Buffer.from([1]));
const resource = new AudioResource([], [stream], null, 5);
await started(resource);
expect(resource.readable).toBe(true);
expect(resource.read()).toEqual(Buffer.from([1]));
for (let i = 0; i < 5; i++) {
await wait();
expect(resource.readable).toBe(true);
expect(resource.read()).toBe(SILENCE_FRAME);
}
await wait();
expect(resource.readable).toBe(false);
expect(resource.read()).toBe(null);
});
});

View File

@@ -0,0 +1,49 @@
import { Edge, findPipeline, StreamType, TransformerType } from '../TransformerGraph';
const noConstraint = () => true;
/**
* Converts a pipeline into an easier-to-parse list of stream types within the pipeline
*
* @param pipeline - The pipeline of edges returned by findPipeline(...)
*/
function reducePath(pipeline: Edge[]) {
const streams = [pipeline[0].from.type];
for (const edge of pipeline.slice(1)) {
streams.push(edge.from.type);
}
streams.push(pipeline[pipeline.length - 1].to.type);
return streams;
}
const isVolume = (edge: Edge) => edge.type === TransformerType.InlineVolume;
const containsVolume = (edges: Edge[]) => edges.some(isVolume);
describe('findPipeline (no constraints)', () => {
test.each([StreamType.Arbitrary, StreamType.OggOpus, StreamType.WebmOpus, StreamType.Raw])(
'%s maps to opus with no inline volume',
(type) => {
const pipeline = findPipeline(type, noConstraint);
const path = reducePath(pipeline);
expect(path.length).toBeGreaterThanOrEqual(2);
expect(path[0]).toBe(type);
expect(path.pop()).toBe(StreamType.Opus);
expect(pipeline.some(isVolume)).toBe(false);
},
);
test('opus is unchanged', () => {
expect(findPipeline(StreamType.Opus, noConstraint)).toHaveLength(0);
});
});
describe('findPipeline (volume constraint)', () => {
test.each(Object.values(StreamType))('%s maps to opus with inline volume', (type) => {
const pipeline = findPipeline(type, containsVolume);
const path = reducePath(pipeline);
expect(path.length).toBeGreaterThanOrEqual(2);
expect(path[0]).toBe(type);
expect(path.pop()).toBe(StreamType.Opus);
expect(pipeline.some(isVolume)).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
export {
AudioPlayer,
AudioPlayerStatus,
AudioPlayerState,
NoSubscriberBehavior,
createAudioPlayer,
AudioPlayerBufferingState,
AudioPlayerIdleState,
AudioPlayerPausedState,
AudioPlayerPlayingState,
CreateAudioPlayerOptions,
AudioPlayerEvents,
} from './AudioPlayer';
export { AudioPlayerError } from './AudioPlayerError';
export { AudioResource, CreateAudioResourceOptions, createAudioResource } from './AudioResource';
export { PlayerSubscription } from './PlayerSubscription';
export { StreamType } from './TransformerGraph';

View File

@@ -0,0 +1,22 @@
export * from './joinVoiceChannel';
export * from './audio';
export * from './util';
export * from './receive';
export {
VoiceConnection,
VoiceConnectionState,
VoiceConnectionStatus,
VoiceConnectionConnectingState,
VoiceConnectionDestroyedState,
VoiceConnectionDisconnectedState,
VoiceConnectionDisconnectedBaseState,
VoiceConnectionDisconnectedOtherState,
VoiceConnectionDisconnectedWebSocketState,
VoiceConnectionDisconnectReason,
VoiceConnectionReadyState,
VoiceConnectionSignallingState,
VoiceConnectionEvents,
} from './VoiceConnection';
export { JoinConfig, getVoiceConnection, getVoiceConnections, getGroups } from './DataStore';

View File

@@ -0,0 +1,66 @@
import { createVoiceConnection } from './VoiceConnection';
import type { JoinConfig } from './DataStore';
import type { DiscordGatewayAdapterCreator } from './util/adapter';
/**
* The options that can be given when creating a voice connection.
*/
export interface CreateVoiceConnectionOptions {
/**
* If true, debug messages will be enabled for the voice connection and its
* related components. Defaults to false.
*/
debug?: boolean;
adapterCreator: DiscordGatewayAdapterCreator;
}
/**
* The options that can be given when joining a voice channel.
*/
export interface JoinVoiceChannelOptions {
/**
* The id of the Discord voice channel to join.
*/
channelId: string;
/**
* The id of the guild that the voice channel belongs to.
*/
guildId: string;
/**
* Whether to join the channel deafened (defaults to true)
*/
selfDeaf?: boolean;
/**
* Whether to join the channel muted (defaults to true)
*/
selfMute?: boolean;
/**
* An optional group identifier for the voice connection.
*/
group?: string;
}
/**
* Creates a VoiceConnection to a Discord voice channel.
*
* @param voiceChannel - the voice channel to connect to
* @param options - the options for joining the voice channel
*/
export function joinVoiceChannel(options: JoinVoiceChannelOptions & CreateVoiceConnectionOptions) {
const joinConfig: JoinConfig = {
selfDeaf: true,
selfMute: false,
group: 'default',
...options,
};
return createVoiceConnection(joinConfig, {
adapterCreator: options.adapterCreator,
debug: options.debug,
});
}

View File

@@ -0,0 +1,594 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import { VoiceUDPSocket } from './VoiceUDPSocket';
import { VoiceWebSocket } from './VoiceWebSocket';
import * as secretbox from '../util/Secretbox';
import { Awaited, noop } from '../util/util';
import type { CloseEvent } from 'ws';
import { TypedEmitter } from 'tiny-typed-emitter';
// The number of audio channels required by Discord
const CHANNELS = 2;
const TIMESTAMP_INC = (48000 / 100) * CHANNELS;
const MAX_NONCE_SIZE = 2 ** 32 - 1;
export const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
/**
* The different statuses that a networking instance can hold. The order
* of the states between OpeningWs and Ready is chronological (first the
* instance enters OpeningWs, then it enters Identifying etc.)
*/
export enum NetworkingStatusCode {
OpeningWs,
Identifying,
UdpHandshaking,
SelectingProtocol,
Ready,
Resuming,
Closed,
}
/**
* The initial Networking state. Instances will be in this state when a WebSocket connection to a Discord
* voice gateway is being opened.
*/
export interface NetworkingOpeningWsState {
code: NetworkingStatusCode.OpeningWs;
ws: VoiceWebSocket;
connectionOptions: ConnectionOptions;
}
/**
* The state that a Networking instance will be in when it is attempting to authorize itself.
*/
export interface NetworkingIdentifyingState {
code: NetworkingStatusCode.Identifying;
ws: VoiceWebSocket;
connectionOptions: ConnectionOptions;
}
/**
* The state that a Networking instance will be in when opening a UDP connection to the IP and port provided
* by Discord, as well as performing IP discovery.
*/
export interface NetworkingUdpHandshakingState {
code: NetworkingStatusCode.UdpHandshaking;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: Pick<ConnectionData, 'ssrc'>;
}
/**
* The state that a Networking instance will be in when selecting an encryption protocol for audio packets.
*/
export interface NetworkingSelectingProtocolState {
code: NetworkingStatusCode.SelectingProtocol;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: Pick<ConnectionData, 'ssrc'>;
}
/**
* The state that a Networking instance will be in when it has a fully established connection to a Discord
* voice server.
*/
export interface NetworkingReadyState {
code: NetworkingStatusCode.Ready;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: ConnectionData;
preparedPacket?: Buffer;
}
/**
* The state that a Networking instance will be in when its connection has been dropped unexpectedly, and it
* is attempting to resume an existing session.
*/
export interface NetworkingResumingState {
code: NetworkingStatusCode.Resuming;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: ConnectionData;
preparedPacket?: Buffer;
}
/**
* The state that a Networking instance will be in when it has been destroyed. It cannot be recovered from this
* state.
*/
export interface NetworkingClosedState {
code: NetworkingStatusCode.Closed;
}
/**
* The various states that a networking instance can be in.
*/
export type NetworkingState =
| NetworkingOpeningWsState
| NetworkingIdentifyingState
| NetworkingUdpHandshakingState
| NetworkingSelectingProtocolState
| NetworkingReadyState
| NetworkingResumingState
| NetworkingClosedState;
/**
* Details required to connect to the Discord voice gateway. These details
* are first received on the main bot gateway, in the form of VOICE_SERVER_UPDATE
* and VOICE_STATE_UPDATE packets.
*/
interface ConnectionOptions {
serverId: string;
userId: string;
sessionId: string;
token: string;
endpoint: string;
}
/**
* Information about the current connection, e.g. which encryption mode is to be used on
* the connection, timing information for playback of streams.
*/
export interface ConnectionData {
ssrc: number;
encryptionMode: string;
secretKey: Uint8Array;
sequence: number;
timestamp: number;
packetsPlayed: number;
nonce: number;
nonceBuffer: Buffer;
speaking: boolean;
}
/**
* An empty buffer that is reused in packet encryption by many different networking instances.
*/
const nonce = Buffer.alloc(24);
export interface NetworkingEvents {
debug: (message: string) => Awaited<void>;
error: (error: Error) => Awaited<void>;
stateChange: (oldState: NetworkingState, newState: NetworkingState) => Awaited<void>;
close: (code: number) => Awaited<void>;
}
/**
* Manages the networking required to maintain a voice connection and dispatch audio packets
*/
export class Networking extends TypedEmitter<NetworkingEvents> {
private _state: NetworkingState;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new Networking instance.
*/
public constructor(options: ConnectionOptions, debug: boolean) {
super();
this.onWsOpen = this.onWsOpen.bind(this);
this.onChildError = this.onChildError.bind(this);
this.onWsPacket = this.onWsPacket.bind(this);
this.onWsClose = this.onWsClose.bind(this);
this.onWsDebug = this.onWsDebug.bind(this);
this.onUdpDebug = this.onUdpDebug.bind(this);
this.onUdpClose = this.onUdpClose.bind(this);
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
this._state = {
code: NetworkingStatusCode.OpeningWs,
ws: this.createWebSocket(options.endpoint),
connectionOptions: options,
};
}
/**
* Destroys the Networking instance, transitioning it into the Closed state.
*/
public destroy() {
this.state = {
code: NetworkingStatusCode.Closed,
};
}
/**
* The current state of the networking instance.
*/
public get state(): NetworkingState {
return this._state;
}
/**
* Sets a new state for the networking instance, performing clean-up operations where necessary.
*/
public set state(newState: NetworkingState) {
const oldWs = Reflect.get(this._state, 'ws') as VoiceWebSocket | undefined;
const newWs = Reflect.get(newState, 'ws') as VoiceWebSocket | undefined;
if (oldWs && oldWs !== newWs) {
// The old WebSocket is being freed - remove all handlers from it
oldWs.off('debug', this.onWsDebug);
oldWs.on('error', noop);
oldWs.off('error', this.onChildError);
oldWs.off('open', this.onWsOpen);
oldWs.off('packet', this.onWsPacket);
oldWs.off('close', this.onWsClose);
oldWs.destroy();
}
const oldUdp = Reflect.get(this._state, 'udp') as VoiceUDPSocket | undefined;
const newUdp = Reflect.get(newState, 'udp') as VoiceUDPSocket | undefined;
if (oldUdp && oldUdp !== newUdp) {
oldUdp.on('error', noop);
oldUdp.off('error', this.onChildError);
oldUdp.off('close', this.onUdpClose);
oldUdp.off('debug', this.onUdpDebug);
oldUdp.destroy();
}
const oldState = this._state;
this._state = newState;
this.emit('stateChange', oldState, newState);
/**
* Debug event for Networking.
*
* @event Networking#debug
* @type {string}
*/
this.debug?.(`state change:\nfrom ${stringifyState(oldState)}\nto ${stringifyState(newState)}`);
}
/**
* Creates a new WebSocket to a Discord Voice gateway.
*
* @param endpoint - The endpoint to connect to
* @param debug - Whether to enable debug logging
*/
private createWebSocket(endpoint: string) {
const ws = new VoiceWebSocket(`wss://${endpoint}?v=4`, Boolean(this.debug));
ws.on('error', this.onChildError);
ws.once('open', this.onWsOpen);
ws.on('packet', this.onWsPacket);
ws.once('close', this.onWsClose);
ws.on('debug', this.onWsDebug);
return ws;
}
/**
* Propagates errors from the children VoiceWebSocket and VoiceUDPSocket.
*
* @param error - The error that was emitted by a child
*/
private onChildError(error: Error) {
this.emit('error', error);
}
/**
* Called when the WebSocket opens. Depending on the state that the instance is in,
* it will either identify with a new session, or it will attempt to resume an existing session.
*/
private onWsOpen() {
if (this.state.code === NetworkingStatusCode.OpeningWs) {
const packet = {
op: VoiceOpcodes.Identify,
d: {
server_id: this.state.connectionOptions.serverId,
user_id: this.state.connectionOptions.userId,
session_id: this.state.connectionOptions.sessionId,
token: this.state.connectionOptions.token,
},
};
this.state.ws.sendPacket(packet);
this.state = {
...this.state,
code: NetworkingStatusCode.Identifying,
};
} else if (this.state.code === NetworkingStatusCode.Resuming) {
const packet = {
op: VoiceOpcodes.Resume,
d: {
server_id: this.state.connectionOptions.serverId,
session_id: this.state.connectionOptions.sessionId,
token: this.state.connectionOptions.token,
},
};
this.state.ws.sendPacket(packet);
}
}
/**
* Called when the WebSocket closes. Based on the reason for closing (given by the code parameter),
* the instance will either attempt to resume, or enter the closed state and emit a 'close' event
* with the close code, allowing the user to decide whether or not they would like to reconnect.
*
* @param code - The close code
*/
private onWsClose({ code }: CloseEvent) {
const canResume = code === 4015 || code < 4000;
if (canResume && this.state.code === NetworkingStatusCode.Ready) {
this.state = {
...this.state,
code: NetworkingStatusCode.Resuming,
ws: this.createWebSocket(this.state.connectionOptions.endpoint),
};
} else if (this.state.code !== NetworkingStatusCode.Closed) {
this.destroy();
this.emit('close', code);
}
}
/**
* Called when the UDP socket has closed itself if it has stopped receiving replies from Discord.
*/
private onUdpClose() {
if (this.state.code === NetworkingStatusCode.Ready) {
this.state = {
...this.state,
code: NetworkingStatusCode.Resuming,
ws: this.createWebSocket(this.state.connectionOptions.endpoint),
};
}
}
/**
* Called when a packet is received on the connection's WebSocket.
*
* @param packet - The received packet
*/
private onWsPacket(packet: any) {
if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval);
} else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) {
const { ip, port, ssrc, modes } = packet.d;
const udp = new VoiceUDPSocket({ ip, port });
udp.on('error', this.onChildError);
udp.on('debug', this.onUdpDebug);
udp.once('close', this.onUdpClose);
udp
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
.performIPDiscovery(ssrc)
.then((localConfig) => {
if (this.state.code !== NetworkingStatusCode.UdpHandshaking) return;
this.state.ws.sendPacket({
op: VoiceOpcodes.SelectProtocol,
d: {
protocol: 'udp',
data: {
address: localConfig.ip,
port: localConfig.port,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mode: chooseEncryptionMode(modes),
},
},
});
this.state = {
...this.state,
code: NetworkingStatusCode.SelectingProtocol,
};
})
.catch((error: Error) => this.emit('error', error));
this.state = {
...this.state,
code: NetworkingStatusCode.UdpHandshaking,
udp,
connectionData: {
ssrc,
},
};
} else if (
packet.op === VoiceOpcodes.SessionDescription &&
this.state.code === NetworkingStatusCode.SelectingProtocol
) {
const { mode: encryptionMode, secret_key: secretKey } = packet.d;
this.state = {
...this.state,
code: NetworkingStatusCode.Ready,
connectionData: {
...this.state.connectionData,
encryptionMode,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
secretKey: new Uint8Array(secretKey),
sequence: randomNBit(16),
timestamp: randomNBit(32),
nonce: 0,
nonceBuffer: Buffer.alloc(24),
speaking: false,
packetsPlayed: 0,
},
};
} else if (packet.op === VoiceOpcodes.Resumed && this.state.code === NetworkingStatusCode.Resuming) {
this.state = {
...this.state,
code: NetworkingStatusCode.Ready,
};
this.state.connectionData.speaking = false;
}
}
/**
* Propagates debug messages from the child WebSocket.
*
* @param message - The emitted debug message
*/
private onWsDebug(message: string) {
this.debug?.(`[WS] ${message}`);
}
/**
* Propagates debug messages from the child UDPSocket.
*
* @param message - The emitted debug message
*/
private onUdpDebug(message: string) {
this.debug?.(`[UDP] ${message}`);
}
/**
* Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it.
* It will be stored within the instance, and can be played by dispatchAudio()
*
* @remarks
* Calling this method while there is already a prepared audio packet that has not yet been dispatched
* will overwrite the existing audio packet. This should be avoided.
*
* @param opusPacket - The Opus packet to encrypt
*
* @returns The audio packet that was prepared
*/
public prepareAudioPacket(opusPacket: Buffer) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData);
return state.preparedPacket;
}
/**
* Dispatches the audio packet previously prepared by prepareAudioPacket(opusPacket). The audio packet
* is consumed and cannot be dispatched again.
*/
public dispatchAudio() {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return false;
if (typeof state.preparedPacket !== 'undefined') {
this.playAudioPacket(state.preparedPacket);
state.preparedPacket = undefined;
return true;
}
return false;
}
/**
* Plays an audio packet, updating timing metadata used for playback.
*
* @param audioPacket - The audio packet to play
*/
private playAudioPacket(audioPacket: Buffer) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
const { connectionData } = state;
connectionData.packetsPlayed++;
connectionData.sequence++;
connectionData.timestamp += TIMESTAMP_INC;
if (connectionData.sequence >= 2 ** 16) connectionData.sequence = 0;
if (connectionData.timestamp >= 2 ** 32) connectionData.timestamp = 0;
this.setSpeaking(true);
state.udp.send(audioPacket);
}
/**
* Sends a packet to the voice gateway indicating that the client has start/stopped sending
* audio.
*
* @param speaking - Whether or not the client should be shown as speaking
*/
public setSpeaking(speaking: boolean) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
if (state.connectionData.speaking === speaking) return;
state.connectionData.speaking = speaking;
state.ws.sendPacket({
op: VoiceOpcodes.Speaking,
d: {
speaking: speaking ? 1 : 0,
delay: 0,
ssrc: state.connectionData.ssrc,
},
});
}
/**
* Creates a new audio packet from an Opus packet. This involves encrypting the packet,
* then prepending a header that includes metadata.
*
* @param opusPacket - The Opus packet to prepare
* @param connectionData - The current connection data of the instance
*/
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) {
const packetBuffer = Buffer.alloc(12);
packetBuffer[0] = 0x80;
packetBuffer[1] = 0x78;
const { sequence, timestamp, ssrc } = connectionData;
packetBuffer.writeUIntBE(sequence, 2, 2);
packetBuffer.writeUIntBE(timestamp, 4, 4);
packetBuffer.writeUIntBE(ssrc, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12);
return Buffer.concat([packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData)]);
}
/**
* Encrypts an Opus packet using the format agreed upon by the instance and Discord.
*
* @param opusPacket - The Opus packet to encrypt
* @param connectionData - The current connection data of the instance
*/
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData) {
const { secretKey, encryptionMode } = connectionData;
if (encryptionMode === 'xsalsa20_poly1305_lite') {
connectionData.nonce++;
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);
return [
secretbox.methods.close(opusPacket, connectionData.nonceBuffer, secretKey),
connectionData.nonceBuffer.slice(0, 4),
];
} else if (encryptionMode === 'xsalsa20_poly1305_suffix') {
const random = secretbox.methods.random(24, connectionData.nonceBuffer);
return [secretbox.methods.close(opusPacket, random, secretKey), random];
}
return [secretbox.methods.close(opusPacket, nonce, secretKey)];
}
}
/**
* Returns a random number that is in the range of n bits.
*
* @param n - The number of bits
*/
function randomNBit(n: number) {
return Math.floor(Math.random() * 2 ** n);
}
/**
* Stringifies a NetworkingState.
*
* @param state - The state to stringify
*/
function stringifyState(state: NetworkingState) {
return JSON.stringify({
...state,
ws: Reflect.has(state, 'ws'),
udp: Reflect.has(state, 'udp'),
});
}
/**
* Chooses an encryption mode from a list of given options. Chooses the most preferred option.
*
* @param options - The available encryption options
*/
function chooseEncryptionMode(options: string[]): string {
const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option));
if (!option) {
throw new Error(`No compatible encryption modes. Available include: ${options.join(', ')}`);
}
return option;
}

View File

@@ -0,0 +1,212 @@
import { createSocket, Socket } from 'node:dgram';
import { isIPv4 } from 'node:net';
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* Stores an IP address and port. Used to store socket details for the local client as well as
* for Discord.
*/
export interface SocketConfig {
ip: string;
port: number;
}
interface KeepAlive {
value: number;
timestamp: number;
}
export interface VoiceUDPSocketEvents {
error: (error: Error) => Awaited<void>;
close: () => Awaited<void>;
debug: (message: string) => Awaited<void>;
message: (message: Buffer) => Awaited<void>;
}
/**
* The interval in milliseconds at which keep alive datagrams are sent.
*/
const KEEP_ALIVE_INTERVAL = 5e3;
/**
* The maximum number of keep alive packets which can be missed.
*/
const KEEP_ALIVE_LIMIT = 12;
/**
* The maximum value of the keep alive counter.
*/
const MAX_COUNTER_VALUE = 2 ** 32 - 1;
/**
* Manages the UDP networking for a voice connection.
*/
export class VoiceUDPSocket extends TypedEmitter<VoiceUDPSocketEvents> {
/**
* The underlying network Socket for the VoiceUDPSocket.
*/
private readonly socket: Socket;
/**
* The socket details for Discord (remote)
*/
private readonly remote: SocketConfig;
/**
* A list of keep alives that are waiting to be acknowledged.
*/
private readonly keepAlives: KeepAlive[];
/**
* The counter used in the keep alive mechanism.
*/
private keepAliveCounter = 0;
/**
* The buffer used to write the keep alive counter into.
*/
private readonly keepAliveBuffer: Buffer;
/**
* The Node.js interval for the keep-alive mechanism.
*/
private readonly keepAliveInterval: NodeJS.Timeout;
/**
* The time taken to receive a response to keep alive messages.
*/
public ping?: number;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new VoiceUDPSocket.
*
* @param remote - Details of the remote socket
*/
public constructor(remote: SocketConfig, debug = false) {
super();
this.socket = createSocket('udp4');
this.socket.on('error', (error: Error) => this.emit('error', error));
this.socket.on('message', (buffer: Buffer) => this.onMessage(buffer));
this.socket.on('close', () => this.emit('close'));
this.remote = remote;
this.keepAlives = [];
this.keepAliveBuffer = Buffer.alloc(8);
this.keepAliveInterval = setInterval(() => this.keepAlive(), KEEP_ALIVE_INTERVAL);
setImmediate(() => this.keepAlive());
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
}
/**
* Called when a message is received on the UDP socket.
*
* @param buffer The received buffer
*/
private onMessage(buffer: Buffer): void {
// Handle keep alive message
if (buffer.length === 8) {
const counter = buffer.readUInt32LE(0);
const index = this.keepAlives.findIndex(({ value }) => value === counter);
if (index === -1) return;
this.ping = Date.now() - this.keepAlives[index].timestamp;
// Delete all keep alives up to and including the received one
this.keepAlives.splice(0, index);
}
// Propagate the message
this.emit('message', buffer);
}
/**
* Called at a regular interval to check whether we are still able to send datagrams to Discord.
*/
private keepAlive() {
if (this.keepAlives.length >= KEEP_ALIVE_LIMIT) {
this.debug?.('UDP socket has not received enough responses from Discord - closing socket');
this.destroy();
return;
}
this.keepAliveBuffer.writeUInt32LE(this.keepAliveCounter, 0);
this.send(this.keepAliveBuffer);
this.keepAlives.push({
value: this.keepAliveCounter,
timestamp: Date.now(),
});
this.keepAliveCounter++;
if (this.keepAliveCounter > MAX_COUNTER_VALUE) {
this.keepAliveCounter = 0;
}
}
/**
* Sends a buffer to Discord.
*
* @param buffer - The buffer to send
*/
public send(buffer: Buffer) {
return this.socket.send(buffer, this.remote.port, this.remote.ip);
}
/**
* Closes the socket, the instance will not be able to be reused.
*/
public destroy() {
try {
this.socket.close();
} catch {}
clearInterval(this.keepAliveInterval);
}
/**
* Performs IP discovery to discover the local address and port to be used for the voice connection.
*
* @param ssrc - The SSRC received from Discord
*/
public performIPDiscovery(ssrc: number): Promise<SocketConfig> {
return new Promise((resolve, reject) => {
const listener = (message: Buffer) => {
try {
if (message.readUInt16BE(0) !== 2) return;
const packet = parseLocalPacket(message);
this.socket.off('message', listener);
resolve(packet);
} catch {}
};
this.socket.on('message', listener);
this.socket.once('close', () => reject(new Error('Cannot perform IP discovery - socket closed')));
const discoveryBuffer = Buffer.alloc(74);
discoveryBuffer.writeUInt16BE(1, 0);
discoveryBuffer.writeUInt16BE(70, 2);
discoveryBuffer.writeUInt32BE(ssrc, 4);
this.send(discoveryBuffer);
});
}
}
/**
* Parses the response from Discord to aid with local IP discovery.
*
* @param message - The received message
*/
export function parseLocalPacket(message: Buffer): SocketConfig {
const packet = Buffer.from(message);
const ip = packet.slice(8, packet.indexOf(0, 8)).toString('utf-8');
if (!isIPv4(ip)) {
throw new Error('Malformed IP address');
}
const port = packet.readUInt16BE(packet.length - 2);
return { ip, port };
}

View File

@@ -0,0 +1,179 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import WebSocket, { MessageEvent } from 'ws';
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* Debug event for VoiceWebSocket.
*
* @event VoiceWebSocket#debug
* @type {string}
*/
export interface VoiceWebSocketEvents {
error: (error: Error) => Awaited<void>;
open: (event: WebSocket.Event) => Awaited<void>;
close: (event: WebSocket.CloseEvent) => Awaited<void>;
debug: (message: string) => Awaited<void>;
packet: (packet: any) => Awaited<void>;
}
/**
* An extension of the WebSocket class to provide helper functionality when interacting
* with the Discord Voice gateway.
*/
export class VoiceWebSocket extends TypedEmitter<VoiceWebSocketEvents> {
/**
* The current heartbeat interval, if any.
*/
private heartbeatInterval?: NodeJS.Timeout;
/**
* The time (milliseconds since UNIX epoch) that the last heartbeat acknowledgement packet was received.
* This is set to 0 if an acknowledgement packet hasn't been received yet.
*/
private lastHeartbeatAck: number;
/**
* The time (milliseconds since UNIX epoch) that the last heartbeat was sent. This is set to 0 if a heartbeat
* hasn't been sent yet.
*/
private lastHeatbeatSend: number;
/**
* The number of consecutively missed heartbeats.
*/
private missedHeartbeats = 0;
/**
* The last recorded ping.
*/
public ping?: number;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* The underlying WebSocket of this wrapper.
*/
private readonly ws: WebSocket;
/**
* Creates a new VoiceWebSocket.
*
* @param address - The address to connect to
*/
public constructor(address: string, debug: boolean) {
super();
this.ws = new WebSocket(address);
this.ws.onmessage = (e) => this.onMessage(e);
this.ws.onopen = (e) => this.emit('open', e);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.ws.onerror = (e: Error | WebSocket.ErrorEvent) => this.emit('error', e instanceof Error ? e : e.error);
this.ws.onclose = (e) => this.emit('close', e);
this.lastHeartbeatAck = 0;
this.lastHeatbeatSend = 0;
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
}
/**
* Destroys the VoiceWebSocket. The heartbeat interval is cleared, and the connection is closed.
*/
public destroy() {
try {
this.debug?.('destroyed');
this.setHeartbeatInterval(-1);
this.ws.close(1000);
} catch (error) {
const e = error as Error;
this.emit('error', e);
}
}
/**
* Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them
* as packets.
*
* @param event - The message event
*/
public onMessage(event: MessageEvent) {
if (typeof event.data !== 'string') return;
this.debug?.(`<< ${event.data}`);
let packet: any;
try {
packet = JSON.parse(event.data);
} catch (error) {
const e = error as Error;
this.emit('error', e);
return;
}
if (packet.op === VoiceOpcodes.HeartbeatAck) {
this.lastHeartbeatAck = Date.now();
this.missedHeartbeats = 0;
this.ping = this.lastHeartbeatAck - this.lastHeatbeatSend;
}
/**
* Packet event.
*
* @event VoiceWebSocket#packet
* @type {any}
*/
this.emit('packet', packet);
}
/**
* Sends a JSON-stringifiable packet over the WebSocket.
*
* @param packet - The packet to send
*/
public sendPacket(packet: any) {
try {
const stringified = JSON.stringify(packet);
this.debug?.(`>> ${stringified}`);
return this.ws.send(stringified);
} catch (error) {
const e = error as Error;
this.emit('error', e);
}
}
/**
* Sends a heartbeat over the WebSocket.
*/
private sendHeartbeat() {
this.lastHeatbeatSend = Date.now();
this.missedHeartbeats++;
const nonce = this.lastHeatbeatSend;
return this.sendPacket({
op: VoiceOpcodes.Heartbeat,
d: nonce,
});
}
/**
* Sets/clears an interval to send heartbeats over the WebSocket.
*
* @param ms - The interval in milliseconds. If negative, the interval will be unset
*/
public setHeartbeatInterval(ms: number) {
if (typeof this.heartbeatInterval !== 'undefined') clearInterval(this.heartbeatInterval);
if (ms > 0) {
this.heartbeatInterval = setInterval(() => {
if (this.lastHeatbeatSend !== 0 && this.missedHeartbeats >= 3) {
// Missed too many heartbeats - disconnect
this.ws.close();
this.setHeartbeatInterval(-1);
}
this.sendHeartbeat();
}, ms);
}
}
}

View File

@@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { createSocket as _createSocket } from 'node:dgram';
import EventEmitter, { once } from 'node:events';
import { VoiceUDPSocket } from '../VoiceUDPSocket';
jest.mock('node:dgram');
jest.useFakeTimers();
const createSocket = _createSocket as unknown as jest.Mock<typeof _createSocket>;
beforeEach(() => {
createSocket.mockReset();
});
class FakeSocket extends EventEmitter {
public send(buffer: Buffer, port: number, address: string) {}
public close() {
this.emit('close');
}
}
// ip = 91.90.123.93, port = 54148
// eslint-disable-next-line prettier/prettier
const VALID_RESPONSE = Buffer.from([
0x0, 0x2, 0x0, 0x46, 0x0, 0x4, 0xeb, 0x23, 0x39, 0x31, 0x2e, 0x39, 0x30, 0x2e, 0x31, 0x32, 0x33, 0x2e, 0x39, 0x33,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd3, 0x84,
]);
function wait() {
return new Promise((resolve) => {
setImmediate(resolve);
jest.advanceTimersToNextTimer();
});
}
describe('VoiceUDPSocket#performIPDiscovery', () => {
let socket: VoiceUDPSocket;
afterEach(() => {
socket.destroy();
});
/*
Ensures that the UDP socket sends data and parses the response correctly
*/
test('Resolves and cleans up with a successful flow', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation((buffer: Buffer, port: number, address: string) => {
fake.emit('message', VALID_RESPONSE);
});
createSocket.mockImplementation((type) => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
expect(fake.listenerCount('message')).toBe(1);
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
ip: '91.90.123.93',
port: 54148,
});
// Ensure clean up occurs
expect(fake.listenerCount('message')).toBe(1);
});
/*
In the case where an unrelated message is received before the IP discovery buffer,
the UDP socket should wait indefinitely until the correct buffer arrives.
*/
test('Waits for a valid response in an unexpected flow', async () => {
const fake = new FakeSocket();
const fakeResponse = Buffer.from([1, 2, 3, 4, 5]);
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
fake.emit('message', fakeResponse);
await wait();
fake.emit('message', VALID_RESPONSE);
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
expect(fake.listenerCount('message')).toBe(1);
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
ip: '91.90.123.93',
port: 54148,
});
// Ensure clean up occurs
expect(fake.listenerCount('message')).toBe(1);
});
test('Rejects if socket closes before IP discovery can be completed', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
await wait();
fake.close();
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
await expect(socket.performIPDiscovery(1234)).rejects.toThrowError();
});
test('Stays alive when messages are echoed back', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation(async (buffer: Buffer) => {
await wait();
fake.emit('message', buffer);
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 30; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(false);
});
test('Emits an error when no response received to keep alive messages', async () => {
const fake = new FakeSocket();
fake.send = jest.fn();
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 15; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(true);
});
test('Recovers from intermittent responses', async () => {
const fake = new FakeSocket();
const fakeSend = jest.fn();
fake.send = fakeSend;
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 10; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
fakeSend.mockImplementation(async (buffer: Buffer) => {
await wait();
fake.emit('message', buffer);
});
expect(closed).toBe(false);
for (let i = 0; i < 30; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import EventEmitter, { once } from 'node:events';
import WS from 'jest-websocket-mock';
import { VoiceWebSocket } from '../VoiceWebSocket';
beforeEach(() => {
WS.clean();
});
function onceIgnoreError<T extends EventEmitter>(target: T, event: string) {
return new Promise((resolve) => {
target.on(event, resolve);
});
}
function onceOrThrow<T extends EventEmitter>(target: T, event: string, after: number) {
return new Promise((resolve, reject) => {
target.on(event, resolve);
setTimeout(() => reject(new Error('Time up')), after);
});
}
describe('VoiceWebSocket: packet parsing', () => {
test('Parses and emits packets', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const dummy = { value: 3 };
const rcv = once(ws, 'packet');
server.send(dummy);
await expect(rcv).resolves.toEqual([dummy]);
});
test('Recovers from invalid packets', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
let rcv = once(ws, 'packet');
server.send('asdf');
await expect(rcv).rejects.toThrowError();
const dummy = { op: 1234 };
rcv = once(ws, 'packet');
server.send(JSON.stringify(dummy));
await expect(rcv).resolves.toEqual([dummy]);
});
});
describe('VoiceWebSocket: event propagation', () => {
test('open', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
const rcv = once(ws, 'open');
await server.connected;
await expect(rcv).resolves.toBeTruthy();
});
test('close (clean)', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcv = once(ws, 'close');
server.close();
await expect(rcv).resolves.toBeTruthy();
});
test('close (error)', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcvError = once(ws, 'error');
const rcvClose = onceIgnoreError(ws, 'close');
server.error();
await expect(rcvError).resolves.toBeTruthy();
await expect(rcvClose).resolves.toBeTruthy();
});
});
describe('VoiceWebSocket: heartbeating', () => {
test('Normal heartbeat flow', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcv = onceOrThrow(ws, 'close', 750);
ws.setHeartbeatInterval(50);
for (let i = 0; i < 10; i++) {
const packet: any = await server.nextMessage;
expect(packet).toMatchObject({
op: VoiceOpcodes.Heartbeat,
});
server.send({
op: VoiceOpcodes.HeartbeatAck,
d: packet.d,
});
expect(ws.ping).toBeGreaterThanOrEqual(0);
}
ws.setHeartbeatInterval(-1);
await expect(rcv).rejects.toThrowError();
});
test('Closes when no ack is received', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
// eslint-disable-next-line @typescript-eslint/no-empty-function
ws.on('error', () => {});
await server.connected;
const rcv = onceIgnoreError(ws, 'close');
ws.setHeartbeatInterval(50);
await expect(rcv).resolves.toBeTruthy();
expect(ws.ping).toBe(undefined);
expect(server.messages.length).toBe(3);
});
});

View File

@@ -0,0 +1,3 @@
export * from './Networking';
export * from './VoiceUDPSocket';
export * from './VoiceWebSocket';

View File

@@ -0,0 +1,89 @@
import { Readable, ReadableOptions } from 'node:stream';
import { SILENCE_FRAME } from '../audio/AudioPlayer';
/**
* The different behaviors an audio receive stream can have for deciding when to end.
*/
export enum EndBehaviorType {
/**
* The stream will only end when manually destroyed.
*/
Manual,
/**
* The stream will end after a given time period of silence/no audio packets.
*/
AfterSilence,
/**
* The stream will end after a given time period of no audio packets.
*/
AfterInactivity,
}
export type EndBehavior =
| {
behavior: EndBehaviorType.Manual;
}
| {
behavior: EndBehaviorType.AfterSilence | EndBehaviorType.AfterInactivity;
duration: number;
};
export interface AudioReceiveStreamOptions extends ReadableOptions {
end: EndBehavior;
}
export function createDefaultAudioReceiveStreamOptions(): AudioReceiveStreamOptions {
return {
end: {
behavior: EndBehaviorType.Manual,
},
};
}
/**
* A readable stream of Opus packets received from a specific entity
* in a Discord voice connection.
*/
export class AudioReceiveStream extends Readable {
/**
* The end behavior of the receive stream.
*/
public readonly end: EndBehavior;
private endTimeout?: NodeJS.Timeout;
public constructor({ end, ...options }: AudioReceiveStreamOptions) {
super({
...options,
objectMode: true,
});
this.end = end;
}
public override push(buffer: Buffer | null) {
if (buffer) {
if (
this.end.behavior === EndBehaviorType.AfterInactivity ||
(this.end.behavior === EndBehaviorType.AfterSilence &&
(buffer.compare(SILENCE_FRAME) !== 0 || typeof this.endTimeout === 'undefined'))
) {
this.renewEndTimeout(this.end);
}
}
return super.push(buffer);
}
private renewEndTimeout(end: EndBehavior & { duration: number }) {
if (this.endTimeout) {
clearTimeout(this.endTimeout);
}
this.endTimeout = setTimeout(() => this.push(null), end.duration);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public override _read() {}
}

View File

@@ -0,0 +1,112 @@
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* The known data for a user in a Discord voice connection.
*/
export interface VoiceUserData {
/**
* The SSRC of the user's audio stream.
*/
audioSSRC: number;
/**
* The SSRC of the user's video stream (if one exists)
* Cannot be 0. If undefined, the user has no video stream.
*/
videoSSRC?: number;
/**
* The Discord user id of the user.
*/
userId: string;
}
/**
* The events that an SSRCMap may emit.
*/
export interface SSRCMapEvents {
create: (newData: VoiceUserData) => Awaited<void>;
update: (oldData: VoiceUserData | undefined, newData: VoiceUserData) => Awaited<void>;
delete: (deletedData: VoiceUserData) => Awaited<void>;
}
/**
* Maps audio SSRCs to data of users in voice connections.
*/
export class SSRCMap extends TypedEmitter<SSRCMapEvents> {
/**
* The underlying map.
*/
private readonly map: Map<number, VoiceUserData>;
public constructor() {
super();
this.map = new Map();
}
/**
* Updates the map with new user data
*
* @param data The data to update with
*/
public update(data: VoiceUserData) {
const existing = this.map.get(data.audioSSRC);
const newValue = {
...this.map.get(data.audioSSRC),
...data,
};
this.map.set(data.audioSSRC, newValue);
if (!existing) this.emit('create', newValue);
this.emit('update', existing, newValue);
}
/**
* Gets the stored voice data of a user.
*
* @param target The target, either their user id or audio SSRC
*/
public get(target: number | string) {
if (typeof target === 'number') {
return this.map.get(target);
}
for (const data of this.map.values()) {
if (data.userId === target) {
return data;
}
}
return undefined;
}
/**
* Deletes the stored voice data about a user.
*
* @param target The target of the delete operation, either their audio SSRC or user id
*
* @returns The data that was deleted, if any
*/
public delete(target: number | string) {
if (typeof target === 'number') {
const existing = this.map.get(target);
if (existing) {
this.map.delete(target);
this.emit('delete', existing);
}
return existing;
}
for (const [audioSSRC, data] of this.map.entries()) {
if (data.userId === target) {
this.map.delete(audioSSRC);
this.emit('delete', data);
return data;
}
}
return undefined;
}
}

View File

@@ -0,0 +1,62 @@
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* The events that a SpeakingMap can emit.
*/
export interface SpeakingMapEvents {
/**
* Emitted when a user starts speaking.
*/
start: (userId: string) => Awaited<void>;
/**
* Emitted when a user stops speaking.
*/
end: (userId: string) => Awaited<void>;
}
/**
* Tracks the speaking states of users in a voice channel.
*/
export class SpeakingMap extends TypedEmitter<SpeakingMapEvents> {
/**
* The delay after a packet is received from a user until they're marked as not speaking anymore.
*/
public static readonly DELAY = 100;
/**
* The currently speaking users, mapped to the milliseconds since UNIX epoch at which they started speaking.
*/
public readonly users: Map<string, number>;
private readonly speakingTimeouts: Map<string, NodeJS.Timeout>;
public constructor() {
super();
this.users = new Map();
this.speakingTimeouts = new Map();
}
public onPacket(userId: string) {
const timeout = this.speakingTimeouts.get(userId);
if (timeout) {
clearTimeout(timeout);
} else {
this.users.set(userId, Date.now());
this.emit('start', userId);
}
this.startTimeout(userId);
}
private startTimeout(userId: string) {
this.speakingTimeouts.set(
userId,
setTimeout(() => {
this.emit('end', userId);
this.speakingTimeouts.delete(userId);
this.users.delete(userId);
}, SpeakingMap.DELAY),
);
}
}

View File

@@ -0,0 +1,195 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import type { ConnectionData } from '../networking/Networking';
import { methods } from '../util/Secretbox';
import type { VoiceConnection } from '../VoiceConnection';
import {
AudioReceiveStream,
AudioReceiveStreamOptions,
createDefaultAudioReceiveStreamOptions,
} from './AudioReceiveStream';
import { SpeakingMap } from './SpeakingMap';
import { SSRCMap } from './SSRCMap';
/**
* Attaches to a VoiceConnection, allowing you to receive audio packets from other
* users that are speaking.
*
* @beta
*/
export class VoiceReceiver {
/**
* The attached connection of this receiver.
*/
public readonly voiceConnection;
/**
* Maps SSRCs to Discord user ids.
*/
public readonly ssrcMap: SSRCMap;
/**
* The current audio subscriptions of this receiver.
*/
public readonly subscriptions: Map<string, AudioReceiveStream>;
/**
* The connection data of the receiver.
*
* @internal
*/
public connectionData: Partial<ConnectionData>;
/**
* The speaking map of the receiver.
*/
public readonly speaking: SpeakingMap;
public constructor(voiceConnection: VoiceConnection) {
this.voiceConnection = voiceConnection;
this.ssrcMap = new SSRCMap();
this.speaking = new SpeakingMap();
this.subscriptions = new Map();
this.connectionData = {};
this.onWsPacket = this.onWsPacket.bind(this);
this.onUdpMessage = this.onUdpMessage.bind(this);
}
/**
* Called when a packet is received on the attached connection's WebSocket.
*
* @param packet The received packet
*
* @internal
*/
public onWsPacket(packet: any) {
if (packet.op === VoiceOpcodes.ClientDisconnect && typeof packet.d?.user_id === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.ssrcMap.delete(packet.d.user_id);
} else if (
packet.op === VoiceOpcodes.Speaking &&
typeof packet.d?.user_id === 'string' &&
typeof packet.d?.ssrc === 'number'
) {
this.ssrcMap.update({ userId: packet.d.user_id, audioSSRC: packet.d.ssrc });
} else if (
packet.op === VoiceOpcodes.ClientConnect &&
typeof packet.d?.user_id === 'string' &&
typeof packet.d?.audio_ssrc === 'number'
) {
this.ssrcMap.update({
userId: packet.d.user_id,
audioSSRC: packet.d.audio_ssrc,
videoSSRC: packet.d.video_ssrc === 0 ? undefined : packet.d.video_ssrc,
});
}
}
private decrypt(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array) {
// Choose correct nonce depending on encryption
let end;
if (mode === 'xsalsa20_poly1305_lite') {
buffer.copy(nonce, 0, buffer.length - 4);
end = buffer.length - 4;
} else if (mode === 'xsalsa20_poly1305_suffix') {
buffer.copy(nonce, 0, buffer.length - 24);
end = buffer.length - 24;
} else {
buffer.copy(nonce, 0, 0, 12);
}
// Open packet
const decrypted = methods.open(buffer.slice(12, end), nonce, secretKey);
if (!decrypted) return;
return Buffer.from(decrypted);
}
/**
* Parses an audio packet, decrypting it to yield an Opus packet.
*
* @param buffer The buffer to parse
* @param mode The encryption mode
* @param nonce The nonce buffer used by the connection for encryption
* @param secretKey The secret key used by the connection for encryption
*
* @returns The parsed Opus packet
*/
private parsePacket(buffer: Buffer, mode: string, nonce: Buffer, secretKey: Uint8Array) {
let packet = this.decrypt(buffer, mode, nonce, secretKey);
if (!packet) return;
// Strip RTP Header Extensions (one-byte only)
if (packet[0] === 0xbe && packet[1] === 0xde && packet.length > 4) {
const headerExtensionLength = packet.readUInt16BE(2);
let offset = 4;
for (let i = 0; i < headerExtensionLength; i++) {
const byte = packet[offset];
offset++;
if (byte === 0) continue;
offset += 1 + (byte >> 4);
}
// Skip over undocumented Discord byte (if present)
const byte = packet.readUInt8(offset);
if (byte === 0x00 || byte === 0x02) offset++;
packet = packet.slice(offset);
}
return packet;
}
/**
* Called when the UDP socket of the attached connection receives a message.
*
* @param msg The received message
*
* @internal
*/
public onUdpMessage(msg: Buffer) {
if (msg.length <= 8) return;
const ssrc = msg.readUInt32BE(8);
const userData = this.ssrcMap.get(ssrc);
if (!userData) return;
this.speaking.onPacket(userData.userId);
const stream = this.subscriptions.get(userData.userId);
if (!stream) return;
if (this.connectionData.encryptionMode && this.connectionData.nonceBuffer && this.connectionData.secretKey) {
const packet = this.parsePacket(
msg,
this.connectionData.encryptionMode,
this.connectionData.nonceBuffer,
this.connectionData.secretKey,
);
if (packet) {
stream.push(packet);
} else {
stream.destroy(new Error('Failed to parse packet'));
}
}
}
/**
* Creates a subscription for the given user id.
*
* @param target The id of the user to subscribe to
*
* @returns A readable stream of Opus packets received from the target
*/
public subscribe(userId: string, options?: Partial<AudioReceiveStreamOptions>) {
const existing = this.subscriptions.get(userId);
if (existing) return existing;
const stream = new AudioReceiveStream({
...createDefaultAudioReceiveStreamOptions(),
...options,
});
stream.once('close', () => this.subscriptions.delete(userId));
this.subscriptions.set(userId, stream);
return stream;
}
}

View File

@@ -0,0 +1,72 @@
import { SILENCE_FRAME } from '../../audio/AudioPlayer';
import { AudioReceiveStream, EndBehaviorType } from '../AudioReceiveStream';
const DUMMY_BUFFER = Buffer.allocUnsafe(16);
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function stepSilence(stream: AudioReceiveStream, increment: number) {
stream.push(SILENCE_FRAME);
await wait(increment);
expect(stream.readable).toBe(true);
}
describe('AudioReceiveStream', () => {
test('Manual end behavior', async () => {
const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.Manual } });
stream.push(DUMMY_BUFFER);
expect(stream.readable).toBe(true);
await wait(200);
stream.push(DUMMY_BUFFER);
expect(stream.readable).toBe(true);
});
// TODO: Fix this test
// test('AfterSilence end behavior', async () => {
// const duration = 100;
// const increment = 20;
// const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.AfterSilence, duration: 100 } });
// stream.resume();
// for (let i = increment; i < duration / 2; i += increment) {
// await stepSilence(stream, increment);
// }
// stream.push(DUMMY_BUFFER);
// for (let i = increment; i < duration; i += increment) {
// await stepSilence(stream, increment);
// }
// await wait(increment);
// expect(stream.readableEnded).toBe(true);
// });
test('AfterInactivity end behavior', async () => {
const duration = 100;
const increment = 20;
const stream = new AudioReceiveStream({ end: { behavior: EndBehaviorType.AfterInactivity, duration: 100 } });
stream.resume();
for (let i = increment; i < duration / 2; i += increment) {
await stepSilence(stream, increment);
}
stream.push(DUMMY_BUFFER);
for (let i = increment; i < duration; i += increment) {
await stepSilence(stream, increment);
}
await wait(increment);
expect(stream.readableEnded).toBe(false);
await wait(duration - increment);
expect(stream.readableEnded).toBe(true);
});
});

View File

@@ -0,0 +1,59 @@
import EventEmitter, { once } from 'node:events';
import { SSRCMap, VoiceUserData } from '../SSRCMap';
function onceOrThrow<T extends EventEmitter>(target: T, event: string, after: number) {
return new Promise((resolve, reject) => {
target.on(event, resolve);
setTimeout(() => reject(new Error('Time up')), after);
});
}
describe('SSRCMap', () => {
test('update persists data and emits correctly', async () => {
const fixture1: VoiceUserData = {
audioSSRC: 1,
userId: '123',
};
const fixture2: VoiceUserData = {
...fixture1,
videoSSRC: 2,
};
const map = new SSRCMap();
process.nextTick(() => map.update(fixture1));
let [oldData, newData] = await once(map, 'update');
expect(oldData).toBeUndefined();
expect(newData).toMatchObject(fixture1);
expect(map.get(fixture1.audioSSRC)).toMatchObject(fixture1);
process.nextTick(() => map.update(fixture2));
[oldData, newData] = await once(map, 'update');
expect(oldData).toMatchObject(fixture1);
expect(newData).toMatchObject(fixture2);
expect(map.get(fixture1.userId)).toMatchObject(fixture2);
});
test('delete removes data and emits correctly', async () => {
const fixture1: VoiceUserData = {
audioSSRC: 1,
userId: '123',
};
const map = new SSRCMap();
map.delete(fixture1.audioSSRC);
await expect(onceOrThrow(map, 'delete', 5)).rejects.toThrow();
map.update(fixture1);
process.nextTick(() => map.delete(fixture1.audioSSRC));
await expect(once(map, 'delete')).resolves.toMatchObject([fixture1]);
map.delete(fixture1.audioSSRC);
await expect(onceOrThrow(map, 'delete', 5)).rejects.toThrow();
map.update(fixture1);
process.nextTick(() => map.delete(fixture1.userId));
await expect(once(map, 'delete')).resolves.toMatchObject([fixture1]);
expect(map.get(fixture1.audioSSRC)).toBeUndefined();
});
});

View File

@@ -0,0 +1,32 @@
import { noop } from '../../util/util';
import { SpeakingMap } from '../SpeakingMap';
jest.useFakeTimers();
describe('SpeakingMap', () => {
test('Emits start and end', () => {
const speaking = new SpeakingMap();
const userId = '123';
const starts: string[] = [];
const ends: string[] = [];
speaking.on('start', (userId) => void starts.push(userId));
speaking.on('end', (userId) => void ends.push(userId));
for (let i = 0; i < 10; i++) {
speaking.onPacket(userId);
setTimeout(noop, SpeakingMap.DELAY / 2);
jest.advanceTimersToNextTimer();
expect(starts).toEqual([userId]);
expect(ends).toEqual([]);
}
jest.advanceTimersToNextTimer();
expect(ends).toEqual([userId]);
speaking.onPacket(userId);
jest.advanceTimersToNextTimer();
expect(starts).toEqual([userId, userId]);
});
});

View File

@@ -0,0 +1,209 @@
/* eslint-disable @typescript-eslint/dot-notation */
import { VoiceReceiver } from '../VoiceReceiver';
import { VoiceConnection as _VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
import { RTP_PACKET_DESKTOP, RTP_PACKET_CHROME, RTP_PACKET_ANDROID } from './fixtures/rtp';
import { once } from 'node:events';
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import { methods } from '../../util/Secretbox';
jest.mock('../../VoiceConnection');
jest.mock('../SSRCMap');
const openSpy = jest.spyOn(methods, 'open');
openSpy.mockImplementation((buffer) => buffer);
const VoiceConnection = _VoiceConnection as unknown as jest.Mocked<typeof _VoiceConnection>;
function nextTick() {
return new Promise((resolve) => process.nextTick(resolve));
}
function* rangeIter(start: number, end: number) {
for (let i = start; i <= end; i++) {
yield i;
}
}
function range(start: number, end: number) {
return Buffer.from([...rangeIter(start, end)]);
}
describe('VoiceReceiver', () => {
let voiceConnection: _VoiceConnection;
let receiver: VoiceReceiver;
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
voiceConnection = new VoiceConnection({} as any, {} as any);
voiceConnection.state = {
status: VoiceConnectionStatus.Signalling,
} as any;
receiver = new VoiceReceiver(voiceConnection);
receiver['connectionData'] = {
encryptionMode: 'dummy',
nonceBuffer: Buffer.alloc(0),
secretKey: Buffer.alloc(0),
};
});
test.each([
['RTP Packet Desktop', RTP_PACKET_DESKTOP],
['RTP Packet Chrome', RTP_PACKET_CHROME],
['RTP Packet Android', RTP_PACKET_ANDROID],
])('onUdpMessage: %s', async (testName, RTP_PACKET) => {
receiver['decrypt'] = jest.fn().mockImplementationOnce(() => RTP_PACKET.decrypted);
const spy = jest.spyOn(receiver.ssrcMap, 'get');
spy.mockImplementation(() => ({
audioSSRC: RTP_PACKET.ssrc,
userId: '123',
}));
const stream = receiver.subscribe('123');
receiver['onUdpMessage'](RTP_PACKET.packet);
await nextTick();
expect(stream.read()).toEqual(RTP_PACKET.opusFrame);
});
test('onUdpMessage: <8 bytes packet', () => {
expect(() => receiver['onUdpMessage'](Buffer.alloc(4))).not.toThrow();
});
test('onUdpMessage: destroys stream on decrypt failure', async () => {
receiver['decrypt'] = jest.fn().mockImplementationOnce(() => null);
const spy = jest.spyOn(receiver.ssrcMap, 'get');
spy.mockImplementation(() => ({
audioSSRC: RTP_PACKET_DESKTOP.ssrc,
userId: '123',
}));
const stream = receiver.subscribe('123');
const errorEvent = once(stream, 'error');
receiver['onUdpMessage'](RTP_PACKET_DESKTOP.packet);
await nextTick();
await expect(errorEvent).resolves.toMatchObject([expect.any(Error)]);
expect(receiver.subscriptions.size).toBe(0);
});
test('subscribe: only allows one subscribe stream per SSRC', () => {
const spy = jest.spyOn(receiver.ssrcMap, 'get');
spy.mockImplementation(() => ({
audioSSRC: RTP_PACKET_DESKTOP.ssrc,
userId: '123',
}));
const stream = receiver.subscribe('123');
expect(receiver.subscribe('123')).toBe(stream);
});
describe('onWsPacket', () => {
test('CLIENT_DISCONNECT packet', () => {
const spy = jest.spyOn(receiver.ssrcMap, 'delete');
receiver['onWsPacket']({
op: VoiceOpcodes.ClientDisconnect,
d: {
user_id: '123abc',
},
});
expect(spy).toHaveBeenCalledWith('123abc');
});
test('SPEAKING packet', () => {
const spy = jest.spyOn(receiver.ssrcMap, 'update');
receiver['onWsPacket']({
op: VoiceOpcodes.Speaking,
d: {
ssrc: 123,
user_id: '123abc',
speaking: 1,
},
});
expect(spy).toHaveBeenCalledWith({
audioSSRC: 123,
userId: '123abc',
});
});
test('CLIENT_CONNECT packet', () => {
const spy = jest.spyOn(receiver.ssrcMap, 'update');
receiver['onWsPacket']({
op: VoiceOpcodes.ClientConnect,
d: {
audio_ssrc: 123,
video_ssrc: 43,
user_id: '123abc',
},
});
expect(spy).toHaveBeenCalledWith({
audioSSRC: 123,
videoSSRC: 43,
userId: '123abc',
});
receiver['onWsPacket']({
op: VoiceOpcodes.ClientConnect,
d: {
audio_ssrc: 123,
video_ssrc: 0,
user_id: '123abc',
},
});
expect(spy).toHaveBeenCalledWith({
audioSSRC: 123,
videoSSRC: undefined,
userId: '123abc',
});
});
});
describe('decrypt', () => {
const secretKey = new Uint8Array([1, 2, 3, 4]);
beforeEach(() => {
openSpy.mockClear();
});
test('decrypt: xsalsa20_poly1305_lite', () => {
// Arrange
const buffer = range(1, 32);
const nonce = Buffer.alloc(4);
// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_lite', nonce, secretKey);
// Assert
expect(nonce.equals(range(29, 32))).toBe(true);
expect(decrypted.equals(range(13, 28))).toBe(true);
});
test('decrypt: xsalsa20_poly1305_suffix', () => {
// Arrange
const buffer = range(1, 64);
const nonce = Buffer.alloc(24);
// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305_suffix', nonce, secretKey);
// Assert
expect(nonce.equals(range(41, 64))).toBe(true);
expect(decrypted.equals(range(13, 40))).toBe(true);
});
test('decrypt: xsalsa20_poly1305', () => {
// Arrange
const buffer = range(1, 64);
const nonce = Buffer.alloc(12);
// Act
const decrypted = receiver['decrypt'](buffer, 'xsalsa20_poly1305', nonce, secretKey);
// Assert
expect(nonce.equals(range(1, 12))).toBe(true);
expect(decrypted.equals(range(13, 64))).toBe(true);
});
});
});

View File

@@ -0,0 +1,31 @@
export const RTP_PACKET_DESKTOP = {
ssrc: 341124,
packet: Buffer.from([
0x90, 0x78, 0x27, 0xe9, 0xf7, 0xcb, 0xbc, 0xd1, 0x0, 0x5, 0x34, 0x84, 0x8a, 0xbb, 0xe2, 0x97, 0x21, 0x9f, 0x1f,
0x67, 0xcd, 0x17, 0x91, 0x56, 0x43, 0xa0, 0x98, 0xfd, 0xa9, 0x25, 0x81, 0x63, 0x13, 0xb4, 0x1e, 0xae, 0x88, 0xe4,
0x0, 0xed, 0x0, 0x0, 0x0,
]),
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
};
export const RTP_PACKET_CHROME = {
ssrc: 172360,
packet: Buffer.from([
0x80, 0x78, 0x46, 0xdf, 0x27, 0x59, 0x2a, 0xd7, 0x0, 0x2, 0xa1, 0x48, 0x42, 0x9e, 0x53, 0xec, 0x73, 0xc1, 0x71,
0x22, 0x71, 0x60, 0x90, 0xff, 0x1b, 0x20, 0x47, 0x2c, 0xdc, 0x86, 0xc4, 0x9a, 0x0, 0x0, 0x0,
]),
decrypted: Buffer.from([0xf8, 0xff, 0xfe]),
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
};
export const RTP_PACKET_ANDROID = {
ssrc: 172596,
packet: Buffer.from([
0x90, 0x78, 0x39, 0xd0, 0xe0, 0x59, 0xf5, 0x47, 0x0, 0x2, 0xa2, 0x34, 0x12, 0x6d, 0x87, 0x56, 0x25, 0xc8, 0x3e,
0x96, 0xc0, 0x71, 0x9a, 0x1, 0x83, 0xe, 0x1, 0x62, 0x91, 0x95, 0x1f, 0x76, 0x57, 0x15, 0x41, 0xab, 0xee, 0x5b, 0xac,
0x8b, 0x0, 0x0, 0x0,
]),
decrypted: Buffer.from([0xbe, 0xde, 0x0, 0x1, 0x10, 0xff, 0x90, 0x0, 0xf8, 0xff, 0xfe]),
opusFrame: Buffer.from([0xf8, 0xff, 0xfe]),
};

View File

@@ -0,0 +1,4 @@
export * from './VoiceReceiver';
export * from './SSRCMap';
export * from './AudioReceiveStream';
export * from './SpeakingMap';

View File

@@ -0,0 +1,56 @@
interface Methods {
open(buffer: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer | null;
close(opusPacket: Buffer, nonce: Buffer, secretKey: Uint8Array): Buffer;
random(bytes: number, nonce: Buffer): Buffer;
}
const libs = {
sodium: (sodium: any): Methods => ({
open: sodium.api.crypto_secretbox_open_easy,
close: sodium.api.crypto_secretbox_easy,
random: (n: any, buffer?: Buffer) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (!buffer) buffer = Buffer.allocUnsafe(n);
sodium.api.randombytes_buf(buffer);
return buffer;
},
}),
'libsodium-wrappers': (sodium: any): Methods => ({
open: sodium.crypto_secretbox_open_easy,
close: sodium.crypto_secretbox_easy,
random: (n: any) => sodium.randombytes_buf(n),
}),
tweetnacl: (tweetnacl: any): Methods => ({
open: tweetnacl.secretbox.open,
close: tweetnacl.secretbox,
random: (n: any) => tweetnacl.randomBytes(n),
}),
} as const;
const fallbackError = () => {
throw new Error(
`Cannot play audio as no valid encryption package is installed.
- Install sodium, libsodium-wrappers, or tweetnacl.
- Use the generateDependencyReport() function for more information.\n`,
);
};
const methods: Methods = {
open: fallbackError,
close: fallbackError,
random: fallbackError,
};
void (async () => {
for (const libName of Object.keys(libs) as (keyof typeof libs)[]) {
try {
// eslint-disable-next-line
const lib = require(libName);
if (libName === 'libsodium-wrappers' && lib.ready) await lib.ready;
Object.assign(methods, libs[libName](lib));
break;
} catch {}
}
})();
export { methods };

View File

@@ -0,0 +1,16 @@
import { methods } from '../Secretbox';
jest.mock(
'tweetnacl',
() => ({
secretbox: {
// eslint-disable-next-line @typescript-eslint/no-empty-function
open() {},
},
}),
{ virtual: true },
);
test('Does not throw error with a package installed', () => {
// @ts-expect-error
expect(() => methods.open()).not.toThrowError();
});

View File

@@ -0,0 +1,24 @@
import { abortAfter } from '../abortAfter';
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
describe('abortAfter', () => {
test('Aborts after the given delay', () => {
const [ac, signal] = abortAfter(100);
expect(ac.signal).toBe(signal);
expect(signal.aborted).toBe(false);
jest.runAllTimers();
expect(signal.aborted).toBe(true);
});
test('Cleans up when manually aborted', () => {
const [ac, signal] = abortAfter(100);
expect(ac.signal).toBe(signal);
expect(signal.aborted).toBe(false);
clearTimeoutSpy.mockClear();
ac.abort();
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,122 @@
import { demuxProbe } from '../demuxProbe';
import { opus as _opus } from 'prism-media';
import { Readable } from 'node:stream';
import { StreamType } from '../../audio';
import EventEmitter, { once } from 'node:events';
jest.mock('prism-media');
const WebmDemuxer = _opus.WebmDemuxer as unknown as jest.Mock<_opus.WebmDemuxer>;
const OggDemuxer = _opus.OggDemuxer as unknown as jest.Mock<_opus.OggDemuxer>;
async function* gen(n: number) {
for (let i = 0; i < n; i++) {
yield Buffer.from([i]);
await nextTick();
}
}
function range(n: number) {
return Buffer.from(Array.from(Array(n).keys()));
}
const validHead = Buffer.from([
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x02, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
]);
const invalidHead = Buffer.from([
0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x38, 0x01, 0x80, 0xbb, 0, 0, 0, 0, 0,
]);
async function collectStream(stream: Readable): Promise<Buffer> {
let output = Buffer.alloc(0);
await once(stream, 'readable');
for await (const data of stream) {
output = Buffer.concat([output, data]);
}
return output;
}
function nextTick() {
return new Promise((resolve) => process.nextTick(resolve));
}
describe('demuxProbe', () => {
const webmWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
const oggWrite: jest.Mock<(buffer: Buffer) => void> = jest.fn();
beforeAll(() => {
WebmDemuxer.prototype = {
...WebmDemuxer,
...EventEmitter.prototype,
write: webmWrite,
};
OggDemuxer.prototype = {
...OggDemuxer,
...EventEmitter.prototype,
write: oggWrite,
};
});
beforeEach(() => {
webmWrite.mockReset();
oggWrite.mockReset();
});
test('Defaults to arbitrary', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Detects WebM', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
webmWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', validHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.WebmOpus);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Detects Ogg', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oggWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', validHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.OggOpus);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Rejects invalid OpusHead', async () => {
const stream = Readable.from(gen(10), { objectMode: false });
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oggWrite.mockImplementation(function mock(data: Buffer) {
if (data[0] === 5) this.emit('head', invalidHead);
} as any);
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(10));
});
test('Gives up on larger streams', async () => {
const stream = Readable.from(gen(8192), { objectMode: false });
const probe = await demuxProbe(stream);
expect(probe.type).toBe(StreamType.Arbitrary);
await expect(collectStream(probe.stream)).resolves.toEqual(range(8192));
});
test('Propagates errors', async () => {
const testError = new Error('test error');
const stream = new Readable({
read() {
this.destroy(testError);
},
});
await expect(demuxProbe(stream)).rejects.toBe(testError);
});
});

View File

@@ -0,0 +1,54 @@
import EventEmitter from 'node:events';
import { VoiceConnection, VoiceConnectionStatus } from '../../VoiceConnection';
import { entersState } from '../entersState';
function createFakeVoiceConnection(status = VoiceConnectionStatus.Signalling) {
const vc = new EventEmitter() as any;
vc.state = { status };
return vc as VoiceConnection;
}
beforeEach(() => {
jest.useFakeTimers();
});
describe('entersState', () => {
test('Returns the target once the state has been entered before timeout', async () => {
jest.useRealTimers();
const vc = createFakeVoiceConnection();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
const result = await entersState(vc, VoiceConnectionStatus.Ready, 1000);
expect(result).toBe(vc);
});
test('Rejects once the timeout is exceeded', async () => {
const vc = createFakeVoiceConnection();
const promise = entersState(vc, VoiceConnectionStatus.Ready, 1000);
jest.runAllTimers();
await expect(promise).rejects.toThrowError();
});
test('Returns the target once the state has been entered before signal is aborted', async () => {
jest.useRealTimers();
const vc = createFakeVoiceConnection();
const ac = new AbortController();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
process.nextTick(() => vc.emit(VoiceConnectionStatus.Ready, null as any, null as any));
const result = await entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
expect(result).toBe(vc);
});
test('Rejects once the signal is aborted', async () => {
const vc = createFakeVoiceConnection();
const ac = new AbortController();
const promise = entersState(vc, VoiceConnectionStatus.Ready, ac.signal);
ac.abort();
await expect(promise).rejects.toThrowError();
});
test('Resolves immediately when target already in desired state', async () => {
const vc = createFakeVoiceConnection();
await expect(entersState(vc, VoiceConnectionStatus.Signalling, 1000)).resolves.toBe(vc);
});
});

View File

@@ -0,0 +1,12 @@
/**
* Creates an abort controller that aborts after the given time.
*
* @param delay - The time in milliseconds to wait before aborting
*/
export function abortAfter(delay: number): [AbortController, AbortSignal] {
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), delay);
// @ts-ignore
ac.signal.addEventListener('abort', () => clearTimeout(timeout));
return [ac, ac.signal];
}

View File

@@ -0,0 +1,53 @@
import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v9';
/**
* Methods that are provided by the @discordjs/voice library to implementations of
* Discord gateway DiscordGatewayAdapters.
*/
export interface DiscordGatewayAdapterLibraryMethods {
/**
* Call this when you receive a VOICE_SERVER_UPDATE payload that is relevant to the adapter.
*
* @param data - The inner data of the VOICE_SERVER_UPDATE payload
*/
onVoiceServerUpdate(data: GatewayVoiceServerUpdateDispatchData): void;
/**
* Call this when you receive a VOICE_STATE_UPDATE payload that is relevant to the adapter.
*
* @param data - The inner data of the VOICE_STATE_UPDATE payload
*/
onVoiceStateUpdate(data: GatewayVoiceStateUpdateDispatchData): void;
/**
* Call this when the adapter can no longer be used (e.g. due to a disconnect from the main gateway)
*/
destroy(): void;
}
/**
* Methods that are provided by the implementer of a Discord gateway DiscordGatewayAdapter.
*/
export interface DiscordGatewayAdapterImplementerMethods {
/**
* Implement this method such that the given payload is sent to the main Discord gateway connection.
*
* @param payload - The payload to send to the main Discord gateway connection
*
* @returns `false` if the payload definitely failed to send - in this case, the voice connection disconnects
*/
sendPayload(payload: any): boolean;
/**
* This will be called by @discordjs/voice when the adapter can safely be destroyed as it will no
* longer be used.
*/
destroy(): void;
}
/**
* A function used to build adapters. It accepts a methods parameter that contains functions that
* can be called by the implementer when new data is received on its gateway connection. In return,
* the implementer will return some methods that the library can call - e.g. to send messages on
* the gateway, or to signal that the adapter can be removed.
*/
export type DiscordGatewayAdapterCreator = (
methods: DiscordGatewayAdapterLibraryMethods,
) => DiscordGatewayAdapterImplementerMethods;

View File

@@ -0,0 +1,118 @@
import { Readable } from 'node:stream';
import prism from 'prism-media';
import { noop } from './util';
import { StreamType } from '..';
/**
* Takes an Opus Head, and verifies whether the associated Opus audio is suitable to play in a Discord voice channel.
*
* @param opusHead The Opus Head to validate
*
* @returns `true` if suitable to play in a Discord voice channel, otherwise `false`
*/
export function validateDiscordOpusHead(opusHead: Buffer): boolean {
const channels = opusHead.readUInt8(9);
const sampleRate = opusHead.readUInt32LE(12);
return channels === 2 && sampleRate === 48000;
}
/**
* The resulting information after probing an audio stream
*/
export interface ProbeInfo {
/**
* The readable audio stream to use. You should use this rather than the input stream, as the probing
* function can sometimes read the input stream to its end and cause the stream to close.
*/
stream: Readable;
/**
* The recommended stream type for this audio stream.
*/
type: StreamType;
}
/**
* Attempt to probe a readable stream to figure out whether it can be demuxed using an Ogg or WebM Opus demuxer.
*
* @param stream The readable stream to probe
* @param probeSize The number of bytes to attempt to read before giving up on the probe
* @param validator The Opus Head validator function
*
* @experimental
*/
export function demuxProbe(
stream: Readable,
probeSize = 1024,
validator = validateDiscordOpusHead,
): Promise<ProbeInfo> {
return new Promise((resolve, reject) => {
// Preconditions
if (stream.readableObjectMode) reject(new Error('Cannot probe a readable stream in object mode'));
if (stream.readableEnded) reject(new Error('Cannot probe a stream that has ended'));
let readBuffer = Buffer.alloc(0);
let resolved: StreamType | undefined = undefined;
const finish = (type: StreamType) => {
stream.off('data', onData);
stream.off('close', onClose);
stream.off('end', onClose);
stream.pause();
resolved = type;
if (stream.readableEnded) {
resolve({
stream: Readable.from(readBuffer),
type,
});
} else {
if (readBuffer.length > 0) {
stream.push(readBuffer);
}
resolve({
stream,
type,
});
}
};
const foundHead = (type: StreamType) => (head: Buffer) => {
if (validator(head)) {
finish(type);
}
};
const webm = new prism.opus.WebmDemuxer();
webm.once('error', noop);
webm.on('head', foundHead(StreamType.WebmOpus));
const ogg = new prism.opus.OggDemuxer();
ogg.once('error', noop);
ogg.on('head', foundHead(StreamType.OggOpus));
const onClose = () => {
if (!resolved) {
finish(StreamType.Arbitrary);
}
};
const onData = (buffer: Buffer) => {
readBuffer = Buffer.concat([readBuffer, buffer]);
webm.write(buffer);
ogg.write(buffer);
if (readBuffer.length >= probeSize) {
stream.off('data', onData);
stream.pause();
process.nextTick(onClose);
}
};
stream.once('error', reject);
stream.on('data', onData);
stream.once('close', onClose);
stream.once('end', onClose);
});
}

View File

@@ -0,0 +1,54 @@
import type { VoiceConnection, VoiceConnectionStatus } from '../VoiceConnection';
import type { AudioPlayer, AudioPlayerStatus } from '../audio/AudioPlayer';
import { abortAfter } from './abortAfter';
import EventEmitter, { once } from 'node:events';
/**
* Allows a voice connection a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The voice connection that we want to observe the state change for
* @param status - The status that the voice connection should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(
target: VoiceConnection,
status: VoiceConnectionStatus,
timeoutOrSignal: number | AbortSignal,
): Promise<VoiceConnection>;
/**
* Allows an audio player a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The audio player that we want to observe the state change for
* @param status - The status that the audio player should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export function entersState(
target: AudioPlayer,
status: AudioPlayerStatus,
timeoutOrSignal: number | AbortSignal,
): Promise<AudioPlayer>;
/**
* Allows a target a specified amount of time to enter a given state, otherwise rejects with an error.
*
* @param target - The object that we want to observe the state change for
* @param status - The status that the target should be in
* @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation
*/
export async function entersState<T extends VoiceConnection | AudioPlayer>(
target: T,
status: VoiceConnectionStatus | AudioPlayerStatus,
timeoutOrSignal: number | AbortSignal,
) {
if (target.state.status !== status) {
const [ac, signal] =
typeof timeoutOrSignal === 'number' ? abortAfter(timeoutOrSignal) : [undefined, timeoutOrSignal];
try {
await once(target as EventEmitter, status, { signal });
} finally {
ac?.abort();
}
}
return target;
}

View File

@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-require-imports */
import { resolve, dirname } from 'node:path';
import prism from 'prism-media';
/**
* Generates a report of the dependencies used by the \@discordjs/voice module.
* Useful for debugging.
*/
export function generateDependencyReport() {
const report = [];
const addVersion = (name: string) => report.push(`- ${name}: ${version(name)}`);
// general
report.push('Core Dependencies');
addVersion('@discordjs/voice');
addVersion('prism-media');
report.push('');
// opus
report.push('Opus Libraries');
addVersion('@discordjs/opus');
addVersion('opusscript');
report.push('');
// encryption
report.push('Encryption Libraries');
addVersion('sodium');
addVersion('libsodium-wrappers');
addVersion('tweetnacl');
report.push('');
// ffmpeg
report.push('FFmpeg');
try {
const info = prism.FFmpeg.getInfo();
report.push(`- version: ${info.version}`);
report.push(`- libopus: ${info.output.includes('--enable-libopus') ? 'yes' : 'no'}`);
} catch (err) {
report.push('- not found');
}
return ['-'.repeat(50), ...report, '-'.repeat(50)].join('\n');
}
/**
* Tries to find the package.json file for a given module.
*
* @param dir - The directory to look in
* @param packageName - The name of the package to look for
* @param depth - The maximum recursion depth
*/
function findPackageJSON(
dir: string,
packageName: string,
depth: number,
): { name: string; version: string } | undefined {
if (depth === 0) return undefined;
const attemptedPath = resolve(dir, './package.json');
try {
const pkg = require(attemptedPath);
if (pkg.name !== packageName) throw new Error('package.json does not match');
return pkg;
} catch (err) {
return findPackageJSON(resolve(dir, '..'), packageName, depth - 1);
}
}
/**
* Tries to find the version of a dependency.
*
* @param name - The package to find the version of
*/
function version(name: string): string {
try {
const pkg =
name === '@discordjs/voice'
? require('../../package.json')
: findPackageJSON(dirname(require.resolve(name)), name, 3);
return pkg?.version ?? 'not found';
} catch (err) {
return 'not found';
}
}

View File

@@ -0,0 +1,4 @@
export * from './generateDependencyReport';
export * from './entersState';
export * from './adapter';
export * from './demuxProbe';

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const noop = () => {};
export type Awaited<T> = T | Promise<T>;