mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-12 17:43:30 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user