mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-12 01:23:31 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
189
packages/voice/src/DataStore.ts
Normal file
189
packages/voice/src/DataStore.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
723
packages/voice/src/VoiceConnection.ts
Normal file
723
packages/voice/src/VoiceConnection.ts
Normal 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;
|
||||
}
|
||||
627
packages/voice/src/audio/AudioPlayer.ts
Normal file
627
packages/voice/src/audio/AudioPlayer.ts
Normal 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);
|
||||
}
|
||||
18
packages/voice/src/audio/AudioPlayerError.ts
Normal file
18
packages/voice/src/audio/AudioPlayerError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
285
packages/voice/src/audio/AudioResource.ts
Normal file
285
packages/voice/src/audio/AudioResource.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
33
packages/voice/src/audio/PlayerSubscription.ts
Normal file
33
packages/voice/src/audio/PlayerSubscription.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
264
packages/voice/src/audio/TransformerGraph.ts
Normal file
264
packages/voice/src/audio/TransformerGraph.ts
Normal 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));
|
||||
}
|
||||
390
packages/voice/src/audio/__tests__/AudioPlayer.test.ts
Normal file
390
packages/voice/src/audio/__tests__/AudioPlayer.test.ts
Normal 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);
|
||||
});
|
||||
124
packages/voice/src/audio/__tests__/AudioResource.test.ts
Normal file
124
packages/voice/src/audio/__tests__/AudioResource.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
packages/voice/src/audio/__tests__/TransformerGraph.test.ts
Normal file
49
packages/voice/src/audio/__tests__/TransformerGraph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
packages/voice/src/audio/index.ts
Normal file
21
packages/voice/src/audio/index.ts
Normal 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';
|
||||
22
packages/voice/src/index.ts
Normal file
22
packages/voice/src/index.ts
Normal 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';
|
||||
66
packages/voice/src/joinVoiceChannel.ts
Normal file
66
packages/voice/src/joinVoiceChannel.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
594
packages/voice/src/networking/Networking.ts
Normal file
594
packages/voice/src/networking/Networking.ts
Normal 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;
|
||||
}
|
||||
212
packages/voice/src/networking/VoiceUDPSocket.ts
Normal file
212
packages/voice/src/networking/VoiceUDPSocket.ts
Normal 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 };
|
||||
}
|
||||
179
packages/voice/src/networking/VoiceWebSocket.ts
Normal file
179
packages/voice/src/networking/VoiceWebSocket.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
packages/voice/src/networking/__tests__/VoiceUDPSocket.test.ts
Normal file
170
packages/voice/src/networking/__tests__/VoiceUDPSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
packages/voice/src/networking/__tests__/VoiceWebSocket.test.ts
Normal file
121
packages/voice/src/networking/__tests__/VoiceWebSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
3
packages/voice/src/networking/index.ts
Normal file
3
packages/voice/src/networking/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Networking';
|
||||
export * from './VoiceUDPSocket';
|
||||
export * from './VoiceWebSocket';
|
||||
89
packages/voice/src/receive/AudioReceiveStream.ts
Normal file
89
packages/voice/src/receive/AudioReceiveStream.ts
Normal 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() {}
|
||||
}
|
||||
112
packages/voice/src/receive/SSRCMap.ts
Normal file
112
packages/voice/src/receive/SSRCMap.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
62
packages/voice/src/receive/SpeakingMap.ts
Normal file
62
packages/voice/src/receive/SpeakingMap.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
packages/voice/src/receive/VoiceReceiver.ts
Normal file
195
packages/voice/src/receive/VoiceReceiver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
59
packages/voice/src/receive/__tests__/SSRCMap.test.ts
Normal file
59
packages/voice/src/receive/__tests__/SSRCMap.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
32
packages/voice/src/receive/__tests__/SpeakingMap.test.ts
Normal file
32
packages/voice/src/receive/__tests__/SpeakingMap.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
209
packages/voice/src/receive/__tests__/VoiceReceiver.test.ts
Normal file
209
packages/voice/src/receive/__tests__/VoiceReceiver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
packages/voice/src/receive/__tests__/fixtures/rtp.ts
Normal file
31
packages/voice/src/receive/__tests__/fixtures/rtp.ts
Normal 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]),
|
||||
};
|
||||
4
packages/voice/src/receive/index.ts
Normal file
4
packages/voice/src/receive/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './VoiceReceiver';
|
||||
export * from './SSRCMap';
|
||||
export * from './AudioReceiveStream';
|
||||
export * from './SpeakingMap';
|
||||
56
packages/voice/src/util/Secretbox.ts
Normal file
56
packages/voice/src/util/Secretbox.ts
Normal 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 };
|
||||
16
packages/voice/src/util/__tests__/Secretbox.test.ts
Normal file
16
packages/voice/src/util/__tests__/Secretbox.test.ts
Normal 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();
|
||||
});
|
||||
24
packages/voice/src/util/__tests__/abortAfter.test.ts
Normal file
24
packages/voice/src/util/__tests__/abortAfter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
packages/voice/src/util/__tests__/demuxProbe.test.ts
Normal file
122
packages/voice/src/util/__tests__/demuxProbe.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
packages/voice/src/util/__tests__/entersState.test.ts
Normal file
54
packages/voice/src/util/__tests__/entersState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/voice/src/util/abortAfter.ts
Normal file
12
packages/voice/src/util/abortAfter.ts
Normal 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];
|
||||
}
|
||||
53
packages/voice/src/util/adapter.ts
Normal file
53
packages/voice/src/util/adapter.ts
Normal 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;
|
||||
118
packages/voice/src/util/demuxProbe.ts
Normal file
118
packages/voice/src/util/demuxProbe.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
54
packages/voice/src/util/entersState.ts
Normal file
54
packages/voice/src/util/entersState.ts
Normal 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;
|
||||
}
|
||||
83
packages/voice/src/util/generateDependencyReport.ts
Normal file
83
packages/voice/src/util/generateDependencyReport.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
4
packages/voice/src/util/index.ts
Normal file
4
packages/voice/src/util/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './generateDependencyReport';
|
||||
export * from './entersState';
|
||||
export * from './adapter';
|
||||
export * from './demuxProbe';
|
||||
4
packages/voice/src/util/util.ts
Normal file
4
packages/voice/src/util/util.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
export const noop = () => {};
|
||||
|
||||
export type Awaited<T> = T | Promise<T>;
|
||||
Reference in New Issue
Block a user