Repository: discordjs/voice (ts)

Issue:
Voice receive
The library should implement existing voice receive logic from discord.js even though the this is officially undocumented.

- [ ] Allow users to set `selfDeaf` to false when joining a voice channel (to receive packets)
- [ ] Expose messages from UDP and WS
- [ ] Have a "keep alive" option that periodically sends silence packets to a given voice channel to continue receiving audio
- [ ] Readable Opus streams for a given user id or ssrc

Pull request:
feat: implement voice receive
**Please describe the changes this PR makes and why it should be merged:**

Resolves #86 


**Status and versioning classification:**

<!--
Please move lines that apply to you out of the comment:
- Code changes have been tested against the Discord API, or there are no code changes
- This PR changes the library's interface (methods or parameters added)
- This PR includes breaking changes (methods removed or renamed, parameters moved or removed)
- This PR **only** includes non-code changes, like changes to documentation, README, etc.
-->


Patch:
diff --git a/jest.config.js b/jest.config.js
index 723f812..dd7e901 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,7 +2,7 @@ module.exports = {
 	testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'],
 	testEnvironment: 'node',
 	collectCoverage: true,
-	collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests__'],
+	collectCoverageFrom: ['src/**/*.ts', '!src/**/__tests__/**/*.ts'],
 	coverageDirectory: 'coverage',
 	coverageReporters: ['text', 'lcov', 'clover'],
 };
diff --git a/src/audio/AudioPlayer.ts b/src/audio/AudioPlayer.ts
index 7c45dd8..125f5cb 100644
--- a/src/audio/AudioPlayer.ts
+++ b/src/audio/AudioPlayer.ts
@@ -8,7 +8,7 @@ import { PlayerSubscription } from './PlayerSubscription';
 import TypedEmitter from 'typed-emitter';
 
 // The Opus "silent" frame
-const SILENCE_FRAME = Buffer.from([0xf8, 0xff, 0xfe]);
+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
diff --git a/src/index.ts b/src/index.ts
index f7cb895..a8db657 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,6 +1,7 @@
 export * from './joinVoiceChannel';
 export * from './audio';
 export * from './util';
+export * from './receive';
 
 export {
 	VoiceConnection,
diff --git a/src/joinVoiceChannel.ts b/src/joinVoiceChannel.ts
index 90f050b..3889922 100644
--- a/src/joinVoiceChannel.ts
+++ b/src/joinVoiceChannel.ts
@@ -18,8 +18,22 @@ export interface CreateVoiceConnectionOptions {
  * 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;
 }
 
 /**
@@ -30,10 +44,9 @@ export interface JoinVoiceChannelOptions {
  */
 export function joinVoiceChannel(options: JoinVoiceChannelOptions & CreateVoiceConnectionOptions) {
 	const joinConfig: JoinConfig = {
-		channelId: options.channelId,
-		guildId: options.guildId,
 		selfDeaf: true,
 		selfMute: false,
+		...options,
 	};
 
 	return createVoiceConnection(joinConfig, {
diff --git a/src/networking/Networking.ts b/src/networking/Networking.ts
index 105bcec..6f02927 100644
--- a/src/networking/Networking.ts
+++ b/src/networking/Networking.ts
@@ -11,7 +11,7 @@ const CHANNELS = 2;
 const TIMESTAMP_INC = (48000 / 100) * CHANNELS;
 const MAX_NONCE_SIZE = 2 ** 32 - 1;
 
-const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
+export const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
 
 /**
  * The different statuses that a networking instance can hold. The order
@@ -133,12 +133,13 @@ interface ConnectionOptions {
  * Information about the current connection, e.g. which encryption mode is to be used on
  * the connection, timing information for playback of streams.
  */
-interface ConnectionData {
+export interface ConnectionData {
 	ssrc: number;
 	encryptionMode: string;
 	secretKey: Uint8Array;
 	sequence: number;
 	timestamp: number;
+	packetsPlayed: number;
 	nonce: number;
 	nonceBuffer: Buffer;
 	speaking: boolean;
@@ -395,6 +396,7 @@ export class Networking extends EventEmitter {
 					nonce: 0,
 					nonceBuffer: Buffer.alloc(24),
 					speaking: false,
+					packetsPlayed: 0,
 				},
 			};
 		} else if (packet.op === VoiceOPCodes.Resumed && this.state.code === NetworkingStatusCode.Resuming) {
@@ -467,6 +469,7 @@ export class Networking extends EventEmitter {
 		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;
diff --git a/src/networking/VoiceUDPSocket.ts b/src/networking/VoiceUDPSocket.ts
index 7ad3677..32d259b 100644
--- a/src/networking/VoiceUDPSocket.ts
+++ b/src/networking/VoiceUDPSocket.ts
@@ -109,6 +109,8 @@ export class VoiceUDPSocket extends EventEmitter {
 			// Delete all keep alives up to and including the received one
 			this.keepAlives.splice(0, index);
 		}
+		// Propagate the message
+		this.emit('message', buffer);
 	}
 
 	/**
diff --git a/src/networking/index.ts b/src/networking/index.ts
new file mode 100644
index 0000000..59d29bd
--- /dev/null
+++ b/src/networking/index.ts
@@ -0,0 +1,3 @@
+export * from './Networking';
+export * from './VoiceUDPSocket';
+export * from './VoiceWebSocket';
diff --git a/src/receive/AudioReceiveStream.ts b/src/receive/AudioReceiveStream.ts
new file mode 100644
index 0000000..581878f
--- /dev/null
+++ b/src/receive/AudioReceiveStream.ts
@@ -0,0 +1,17 @@
+import { Readable, ReadableOptions } from 'stream';
+
+/**
+ * A readable stream of Opus packets received from a specific entity
+ * in a Discord voice connection.
+ */
+export class AudioReceiveStream extends Readable {
+	public constructor(options?: ReadableOptions) {
+		super({
+			...options,
+			objectMode: true,
+		});
+	}
+
+	// eslint-disable-next-line @typescript-eslint/no-empty-function
+	public _read() {}
+}
diff --git a/src/receive/SSRCMap.ts b/src/receive/SSRCMap.ts
new file mode 100644
index 0000000..713d8bb
--- /dev/null
+++ b/src/receive/SSRCMap.ts
@@ -0,0 +1,101 @@
+import EventEmitter from 'events';
+import TypedEmitter from 'typed-emitter';
+
+/**
+ * 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 {
+	update: (oldData: VoiceUserData | undefined, newData: VoiceUserData) => void;
+	delete: (deletedData: VoiceUserData) => void;
+}
+
+/**
+ * Maps audio SSRCs to data of users in voice connections.
+ */
+export class SSRCMap extends (EventEmitter as new () => 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);
+		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;
+			}
+		}
+	}
+
+	/**
+	 * 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;
+			}
+		}
+	}
+}
diff --git a/src/receive/VoiceReceiver.ts b/src/receive/VoiceReceiver.ts
new file mode 100644
index 0000000..5b30371
--- /dev/null
+++ b/src/receive/VoiceReceiver.ts
@@ -0,0 +1,251 @@
+import { VoiceOPCodes } from 'discord-api-types/v8';
+import { SILENCE_FRAME } from '../audio/AudioPlayer';
+import { ConnectionData, Networking, NetworkingState } from '../networking/Networking';
+import { VoiceUDPSocket } from '../networking/VoiceUDPSocket';
+import { VoiceWebSocket } from '../networking/VoiceWebSocket';
+import { methods } from '../util/Secretbox';
+import type { VoiceConnection } from '../VoiceConnection';
+import { AudioReceiveStream } from './AudioReceiveStream';
+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<number, AudioReceiveStream>;
+
+	/**
+	 * The connection information for this receiver. Used to decrypt incoming packets.
+	 */
+	private connectionData: Partial<ConnectionData>;
+
+	public constructor(voiceConnection: VoiceConnection) {
+		this.voiceConnection = voiceConnection;
+		this.ssrcMap = new SSRCMap();
+		this.subscriptions = new Map();
+		this.connectionData = {};
+
+		const onWsPacket = (packet: any) => this.onWsPacket(packet);
+		const onUdpMessage = (msg: Buffer) => this.onUdpMessage(msg);
+
+		const applyConnectionData = (connectionData: Partial<ConnectionData>) => {
+			this.connectionData = {
+				...this.connectionData,
+				...connectionData,
+			};
+			if (connectionData.packetsPlayed === 0) {
+				this.voiceConnection.playOpusPacket(SILENCE_FRAME);
+			}
+		};
+
+		// Bind listeners for updates
+		const onNetworkingChange = (oldState: NetworkingState, newState: NetworkingState) => {
+			const oldWs = Reflect.get(oldState, 'ws') as VoiceWebSocket | undefined;
+			const oldUdp = Reflect.get(oldState, 'udp') as VoiceUDPSocket | undefined;
+			const newWs = Reflect.get(newState, 'ws') as VoiceWebSocket | undefined;
+			const newUdp = Reflect.get(newState, 'udp') as VoiceUDPSocket | undefined;
+
+			const connectionData = Reflect.get(newState, 'connectionData') as Partial<ConnectionData> | undefined;
+			if (connectionData) applyConnectionData(connectionData);
+
+			if (newWs !== oldWs) {
+				oldWs?.off('packet', onWsPacket);
+				newWs?.on('packet', onWsPacket);
+			}
+
+			if (newUdp !== oldUdp) {
+				oldUdp?.off('message', onUdpMessage);
+				newUdp?.on('message', onUdpMessage);
+			}
+		};
+
+		this.voiceConnection.on('stateChange', (oldState, newState) => {
+			const oldNetworking: Networking | undefined = Reflect.get(oldState, 'networking');
+			const newNetworking: Networking | undefined = Reflect.get(newState, 'networking');
+
+			if (newNetworking !== oldNetworking) {
+				oldNetworking?.off('stateChange', onNetworkingChange);
+				newNetworking?.on('stateChange', onNetworkingChange);
+				if (newNetworking) {
+					const ws = Reflect.get(newNetworking.state, 'ws') as VoiceWebSocket | undefined;
+					const udp = Reflect.get(newNetworking.state, 'udp') as VoiceUDPSocket | undefined;
+					const connectionData = Reflect.get(newNetworking.state, 'connectionData') as
+						| Partial<ConnectionData>
+						| undefined;
+					ws?.on('packet', onWsPacket);
+					udp?.on('message', onUdpMessage);
+					if (connectionData) applyConnectionData(connectionData);
+				}
+			}
+		});
+
+		// Bind listeners for the existing state
+		const networking: Networking | undefined = Reflect.get(voiceConnection.state, 'networking');
+		if (networking) {
+			const ws = Reflect.get(networking.state, 'ws') as VoiceWebSocket | undefined;
+			const udp = Reflect.get(networking.state, 'udp') as VoiceUDPSocket | undefined;
+			const connectionData = Reflect.get(networking.state, 'connectionData') as Partial<ConnectionData> | undefined;
+			ws?.on('packet', onWsPacket);
+			udp?.on('message', onUdpMessage);
+			if (connectionData) applyConnectionData(connectionData);
+		}
+	}
+
+	/**
+	 * Called when a packet is received on the attached connection's WebSocket.
+	 *
+	 * @param packet The received packet
+	 */
+	private onWsPacket(packet: any) {
+		if (packet.op === VoiceOPCodes.ClientDisconnect && typeof packet.d?.user_id === 'string') {
+			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
+	 */
+	private onUdpMessage(msg: Buffer) {
+		if (msg.length <= 8) return;
+		const ssrc = msg.readUInt32BE(8);
+		const stream = this.subscriptions.get(ssrc);
+		if (!stream) return;
+
+		const userData = this.ssrcMap.get(ssrc);
+		if (!userData) 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 target, specified either by their SSRC or user ID.
+	 *
+	 * @param target The audio SSRC or user ID to subscribe to
+	 * @returns A readable stream of Opus packets received from the target
+	 */
+	public subscribe(target: string | number) {
+		const ssrc = this.ssrcMap.get(target)?.audioSSRC;
+		if (!ssrc) {
+			throw new Error(`No known SSRC for ${target}`);
+		}
+
+		const existing = this.subscriptions.get(ssrc);
+		if (existing) return existing;
+
+		const stream = new AudioReceiveStream();
+		stream.once('close', () => this.subscriptions.delete(ssrc));
+		this.subscriptions.set(ssrc, stream);
+		return stream;
+	}
+}
+
+/**
+ * Creates a new voice receiver for the given voice connection.
+ *
+ * @param voiceConnection The voice connection to attach to
+ * @beta
+ * @remarks
+ * Voice receive is an undocumented part of the Discord API - voice receive is not guaranteed
+ * to be stable and may break without notice.
+ */
+export function createVoiceReceiver(voiceConnection: VoiceConnection) {
+	return new VoiceReceiver(voiceConnection);
+}
diff --git a/src/receive/index.ts b/src/receive/index.ts
new file mode 100644
index 0000000..bd3f311
--- /dev/null
+++ b/src/receive/index.ts
@@ -0,0 +1,3 @@
+export * from './VoiceReceiver';
+export * from './SSRCMap';
+export * from './AudioReceiveStream';


Test patch:
diff --git a/src/receive/__tests__/SSRCMap.test.ts b/src/receive/__tests__/SSRCMap.test.ts
new file mode 100644
index 0000000..abe2b8b
--- /dev/null
+++ b/src/receive/__tests__/SSRCMap.test.ts
@@ -0,0 +1,59 @@
+import EventEmitter, { once } from '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();
+	});
+});
diff --git a/src/receive/__tests__/VoiceReceiver.test.ts b/src/receive/__tests__/VoiceReceiver.test.ts
new file mode 100644
index 0000000..df50593
--- /dev/null
+++ b/src/receive/__tests__/VoiceReceiver.test.ts
@@ -0,0 +1,222 @@
+/* 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 EventEmitter, { once } from 'events';
+import { createVoiceReceiver } from '..';
+import { VoiceOPCodes } from 'discord-api-types/v8/gateway';
+import * as fixtures from './fixtures/states';
+
+jest.mock('../../VoiceConnection');
+jest.mock('../SSRCMap');
+
+const VoiceConnection = (_VoiceConnection as unknown) as jest.Mocked<typeof _VoiceConnection>;
+
+function nextTick() {
+	return new Promise((resolve) => process.nextTick(resolve));
+}
+
+describe('VoiceReceiver', () => {
+	let voiceConnection: _VoiceConnection;
+	let receiver: VoiceReceiver;
+
+	beforeEach(() => {
+		voiceConnection = new VoiceConnection({} as any, {} as any);
+		voiceConnection.state = {
+			status: VoiceConnectionStatus.Signalling,
+		} as any;
+		receiver = createVoiceReceiver(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(RTP_PACKET.ssrc);
+
+		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(RTP_PACKET_DESKTOP.ssrc);
+
+		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(RTP_PACKET_DESKTOP.ssrc);
+		expect(receiver.subscribe(RTP_PACKET_DESKTOP.ssrc)).toBe(stream);
+	});
+
+	test('subscribe: refuses unknown SSRC or user IDs', () => {
+		expect(() => receiver.subscribe(RTP_PACKET_DESKTOP.ssrc)).toThrow();
+	});
+
+	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',
+			});
+		});
+	});
+});
+
+test('Receiver tracks state changes', () => {
+	const voiceConnection: any = new EventEmitter();
+	voiceConnection.playOpusPacket = jest.fn();
+
+	voiceConnection.state = fixtures.state1;
+
+	const receiver = createVoiceReceiver(voiceConnection);
+
+	const onWsPacketSpy = jest.fn();
+	receiver['onWsPacket'] = onWsPacketSpy;
+
+	const onUdpMessageSpy = jest.fn();
+	receiver['onUdpMessage'] = onUdpMessageSpy;
+
+	const networking1 = fixtures.state2.vc.networking;
+	voiceConnection.state = fixtures.state2.vc;
+
+	voiceConnection.emit('stateChange', fixtures.state1.vc, voiceConnection.state);
+	fixtures.state2.networking.ws.emit('packet', Symbol('message'));
+	expect(onWsPacketSpy).toHaveBeenCalled();
+	onWsPacketSpy.mockClear();
+
+	networking1.state = fixtures.state3.networking;
+	networking1.emit('stateChange', fixtures.state2.networking, fixtures.state3.networking);
+
+	fixtures.state3.networking.ws.emit('packet', Symbol('message'));
+	expect(onWsPacketSpy).toHaveBeenCalled();
+
+	fixtures.state3.networking.udp.emit('message', Symbol('message'));
+	expect(onUdpMessageSpy).toHaveBeenCalled();
+
+	onWsPacketSpy.mockClear();
+	onUdpMessageSpy.mockClear();
+
+	voiceConnection.state = fixtures.state4.vc;
+	expect(voiceConnection.playOpusPacket).not.toHaveBeenCalled();
+
+	voiceConnection.emit('stateChange', fixtures.state3.vc, voiceConnection.state);
+
+	expect(voiceConnection.playOpusPacket).toHaveBeenCalled();
+
+	fixtures.state4.networking.ws.emit('packet', Symbol('message'));
+	expect(onWsPacketSpy).toHaveBeenCalled();
+
+	fixtures.state4.networking.udp.emit('message', Symbol('message'));
+	expect(onUdpMessageSpy).toHaveBeenCalled();
+});
+
+test('Receiver binds to immediately ready voice connection', () => {
+	const voiceConnection: any = new EventEmitter();
+	voiceConnection.state = fixtures.state4.vc;
+	voiceConnection.playOpusPacket = jest.fn();
+
+	const receiver = createVoiceReceiver(voiceConnection);
+
+	expect(voiceConnection.playOpusPacket).toHaveBeenCalled();
+
+	const onWsPacketSpy = jest.fn();
+	receiver['onWsPacket'] = onWsPacketSpy;
+
+	const onUdpMessageSpy = jest.fn();
+	receiver['onUdpMessage'] = onUdpMessageSpy;
+
+	fixtures.state4.networking.ws.emit('packet', Symbol('message'));
+	expect(onWsPacketSpy).toHaveBeenCalled();
+
+	fixtures.state4.networking.udp.emit('message', Symbol('message'));
+	expect(onUdpMessageSpy).toHaveBeenCalled();
+});
diff --git a/src/receive/__tests__/fixtures/rtp.ts b/src/receive/__tests__/fixtures/rtp.ts
new file mode 100644
index 0000000..76b8a02
--- /dev/null
+++ b/src/receive/__tests__/fixtures/rtp.ts
@@ -0,0 +1,144 @@
+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]),
+};
diff --git a/src/receive/__tests__/fixtures/states.ts b/src/receive/__tests__/fixtures/states.ts
new file mode 100644
index 0000000..c1376e0
--- /dev/null
+++ b/src/receive/__tests__/fixtures/states.ts
@@ -0,0 +1,70 @@
+import EventEmitter from 'events';
+import { NetworkingState, NetworkingStatusCode } from '../../../networking';
+import { VoiceConnectionState, VoiceConnectionStatus } from '../../../VoiceConnection';
+
+// State 1
+const vcState1: VoiceConnectionState = {
+	status: VoiceConnectionStatus.Signalling,
+	adapter: {} as any,
+};
+
+export const state1 = {
+	vc: vcState1,
+};
+
+// State 2
+const networkingState2: NetworkingState = {
+	code: NetworkingStatusCode.Identifying,
+	connectionOptions: {} as any,
+	ws: new EventEmitter() as any,
+};
+const networking1 = new EventEmitter() as any;
+networking1.state = networkingState2;
+const vcState2: VoiceConnectionState = {
+	status: VoiceConnectionStatus.Ready,
+	networking: networking1,
+	adapter: {} as any,
+};
+
+export const state2 = {
+	vc: vcState2,
+	networking: networkingState2,
+};
+
+// State 3
+const networkingState3 = {
+	code: NetworkingStatusCode.Ready,
+	connectionData: {} as any,
+	connectionOptions: {} as any,
+	udp: new EventEmitter() as any,
+	ws: new EventEmitter() as any,
+};
+
+export const state3 = {
+	vc: vcState2,
+	networking: networkingState3,
+};
+
+// State 4
+const networking2 = new EventEmitter() as any;
+const networkingState4 = {
+	code: NetworkingStatusCode.Ready,
+	connectionData: {
+		packetsPlayed: 0,
+	} as any,
+	connectionOptions: {} as any,
+	udp: new EventEmitter() as any,
+	ws: new EventEmitter() as any,
+};
+networking2.state = networkingState4;
+
+const vcState4: VoiceConnectionState = {
+	status: VoiceConnectionStatus.Ready,
+	networking: networking2,
+	adapter: {} as any,
+};
+
+export const state4 = {
+	vc: vcState4,
+	networking: networkingState4,
+};