Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Commit

Permalink
feat(VoiceReceive)!: improve usability (#136)
Browse files Browse the repository at this point in the history
* refactor(VoiceReceiver): begin refactor

* feat(SSRCMap): resolve function

* feat(VoiceConnection): close all streams in non-ready state

* refactor(VoiceReceiver): map by user ID

* feat(VoiceReceiver): allow specifying end type for streams

* feat(VoiceReceiver): add SpeakingMap

* refactor(SSRCMap): remove unused resolve method

* test(VoiceReceiver): add test for SpeakingMap

* test(VoiceReceiver): add tests for AudioReceiveStream

* test(AudioReceiver): strengthen AudioReceiveStream tests

* test(VoiceReceiver): remove inapplicable tests

* test(VoiceConnection): fix test errors

* test(VoiceConnection): test receiver bindings tracking

* test(VoiceReceiver): decrypt

* chore: remove unused code

* fix(AudioReceiveStream): close normally

* feat(Examples): update receiver example

* feat(Examples): create recorder example

* docs(VoiceReceiver): add docs for receive classes

* refactor: suggestions from code review

Co-authored-by: Antonio Román <kyradiscord@gmail.com>

Co-authored-by: Antonio Román <kyradiscord@gmail.com>
  • Loading branch information
amishshah and kyranet authored Aug 10, 2021
1 parent 07e751a commit 687e0e8
Show file tree
Hide file tree
Showing 23 changed files with 764 additions and 251 deletions.
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Examples

| Example | Description |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Basic](./basic) | A simple "Hello World" TypeScript example that plays an mp3 file. Notably, it works with discord.js v12 and so it also contains an example of creating an adapter |
| [Radio Bot](./radio-bot) | A fun JavaScript example of what you can create using @discordjs/voice. A radio bot that plays output from your speakers in a Discord voice channel |
| [Music Bot](./music-bot) | A TypeScript example of a YouTube music bot. Demonstrates how queues can be implemented and how to implement "good" disconnect/reconnection logic |
| [Recorder](./recorder) | An example of using voice receive to create a bot that can record audio from users |
7 changes: 7 additions & 0 deletions examples/recorder/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"root": true,
"extends": "../../.eslintrc.json",
"parserOptions": {
"project": "./tsconfig.eslint.json"
}
}
4 changes: 4 additions & 0 deletions examples/recorder/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package-lock.json
auth.json
tsconfig.tsbuildinfo
recordings/*.ogg
23 changes: 23 additions & 0 deletions examples/recorder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 👂 Recorder Bot

This example shows how you can use the voice receive functionality in @discordjs/voice to record users in voice channels
and save the audio to local Ogg files.

## Usage

```sh-session
# Clone the main repository, and then run:
$ npm install
$ npm run build

# Open this example and install dependencies
$ cd examples/recorder
$ npm install

# Set a bot token (see auth.example.json)
$ cp auth.example.json auth.json
$ nano auth.json

# Start the bot!
$ npm start
```
3 changes: 3 additions & 0 deletions examples/recorder/auth.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"token": "Your Discord bot token here"
}
28 changes: 28 additions & 0 deletions examples/recorder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "receiver-bot",
"version": "0.0.1",
"description": "An example receiver bot written using @discordjs/voice",
"scripts": {
"start": "npm run build && node -r tsconfig-paths/register dist/bot",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix",
"prettier": "prettier --write **/*.{ts,js,json,yml,yaml}",
"build": "tsc",
"build:check": "tsc --noEmit --incremental false"
},
"author": "Amish Shah <contact@shah.gg>",
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.5.3",
"discord-api-types": "^0.22.0",
"discord.js": "^13.0.1",
"libsodium-wrappers": "^0.7.9",
"node-crc": "^1.3.2",
"prism-media": "^2.0.0-alpha.0"
},
"devDependencies": {
"tsconfig-paths": "^3.10.1",
"typescript": "~4.3.5"
}
}
Empty file.
46 changes: 46 additions & 0 deletions examples/recorder/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Discord, { Interaction } from 'discord.js';
import { getVoiceConnection } from '@discordjs/voice';
import { deploy } from './deploy';
import { interactionHandlers } from './interactions';

// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
const { token } = require('../auth.json');

const client = new Discord.Client({ intents: ['GUILD_VOICE_STATES', 'GUILD_MESSAGES', 'GUILDS'] });

client.on('ready', () => console.log('Ready!'));

client.on('messageCreate', async (message) => {
if (!message.guild) return;
if (!client.application?.owner) await client.application?.fetch();

if (message.content.toLowerCase() === '!deploy' && message.author.id === client.application?.owner?.id) {
await deploy(message.guild);
await message.reply('Deployed!');
}
});

/**
* The IDs of the users that can be recorded by the bot.
*/
const recordable = new Set<string>();

client.on('interactionCreate', async (interaction: Interaction) => {
if (!interaction.isCommand() || !interaction.guildId) return;

const handler = interactionHandlers.get(interaction.commandName);

try {
if (handler) {
await handler(interaction, recordable, client, getVoiceConnection(interaction.guildId));
} else {
await interaction.reply('Unknown command');
}
} catch (error) {
console.warn(error);
}
});

client.on('error', console.warn);

void client.login(token);
42 changes: 42 additions & 0 deletions examples/recorder/src/createListeningStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { EndBehaviorType, VoiceReceiver } from '@discordjs/voice';
import { User } from 'discord.js';
import { createWriteStream } from 'fs';
import { opus } from 'prism-media';
import { pipeline } from 'stream';

function getDisplayName(userId: string, user?: User) {
return user ? `${user.username}_${user.discriminator}` : userId;
}

export function createListeningStream(receiver: VoiceReceiver, userId: string, user?: User) {
const opusStream = receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
duration: 100,
},
});

const oggStream = new opus.OggLogicalBitstream({
opusHead: new opus.OpusHead({
channelCount: 2,
sampleRate: 48000,
}),
pageSizeControl: {
maxPackets: 10,
},
});

const filename = `./recordings/${Date.now()}-${getDisplayName(userId, user)}.ogg`;

const out = createWriteStream(filename);

console.log(`👂 Started recording ${filename}`);

pipeline(opusStream, oggStream, out, (err) => {
if (err) {
console.warn(`❌ Error recording file ${filename} - ${err.message}`);
} else {
console.log(`✅ Recorded ${filename}`);
}
});
}
26 changes: 26 additions & 0 deletions examples/recorder/src/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Guild } from 'discord.js';

export const deploy = async (guild: Guild) => {
await guild.commands.set([
{
name: 'join',
description: 'Joins the voice channel that you are in',
},
{
name: 'record',
description: 'Enables recording for a user',
options: [
{
name: 'speaker',
type: 'USER' as const,
description: 'The user to record',
required: true,
},
],
},
{
name: 'leave',
description: 'Leave the voice channel',
},
]);
};
92 changes: 92 additions & 0 deletions examples/recorder/src/interactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from '@discordjs/voice';
import { Client, CommandInteraction, GuildMember, Snowflake } from 'discord.js';
import { createListeningStream } from './createListeningStream';

async function join(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
await interaction.deferReply();
if (!connection) {
if (interaction.member instanceof GuildMember && interaction.member.voice.channel) {
const channel = interaction.member.voice.channel;
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
selfDeaf: false,
selfMute: true,
adapterCreator: channel.guild.voiceAdapterCreator,
});
} else {
await interaction.followUp('Join a voice channel and then try that again!');
return;
}
}

try {
await entersState(connection, VoiceConnectionStatus.Ready, 20e3);
const receiver = connection.receiver;

receiver.speaking.on('start', (userId) => {
if (recordable.has(userId)) {
createListeningStream(receiver, userId, client.users.cache.get(userId));
}
});
} catch (error) {
console.warn(error);
await interaction.followUp('Failed to join voice channel within 20 seconds, please try again later!');
}

await interaction.followUp('Ready!');
}

async function record(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
if (connection) {
const userId = interaction.options.get('speaker')!.value! as Snowflake;
recordable.add(userId);

const receiver = connection.receiver;
if (connection.receiver.speaking.users.has(userId)) {
createListeningStream(receiver, userId, client.users.cache.get(userId));
}

await interaction.reply({ ephemeral: true, content: 'Listening!' });
} else {
await interaction.reply({ ephemeral: true, content: 'Join a voice channel and then try that again!' });
}
}

async function leave(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) {
if (connection) {
connection.destroy();
recordable.clear();
await interaction.reply({ ephemeral: true, content: 'Left the channel!' });
} else {
await interaction.reply({ ephemeral: true, content: 'Not playing in this server!' });
}
}

export const interactionHandlers = new Map<
string,
(
interaction: CommandInteraction,
recordable: Set<Snowflake>,
client: Client,
connection?: VoiceConnection,
) => Promise<void>
>();
interactionHandlers.set('join', join);
interactionHandlers.set('record', record);
interactionHandlers.set('leave', leave);
3 changes: 3 additions & 0 deletions examples/recorder/tsconfig.eslint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}
13 changes: 13 additions & 0 deletions examples/recorder/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@discordjs/voice": ["../../"],
"libsodium-wrappers": ["./node_modules/libsodium-wrappers"]
}
},
"include": ["src/*.ts"],
"exclude": [""]
}
Loading

0 comments on commit 687e0e8

Please sign in to comment.