chore: monorepo setup (#7175)

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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