diff --git a/README.md b/README.md index 732b099..a80b676 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DartJS -DartJS is a Discord.js framework that aims to provide similar voice interface of Discord.js v12. +DartJS is a Discord.js voice framework that aims to provide similar voice interface of Discord.js v12. # Installation @@ -10,16 +10,19 @@ $ npm i --save dartjs > You may need to install encryption library and opus engine as well. -# Why? +# Note -This library was created just for learning purpose. There is no point of using this library unless you really have to :D +* Use `.once` instead of `.on` + +> This library was created just for learning purpose and a personal music bot. You don't have to use this +unless you really want to. # Example ```js const Discord = require("discord.js"); const client = new Discord.Client({ - intents: [Discord.Intents.GUILDS, Discord.Intents.GUILD_VOICE_STATES, Discord.Intents.GUILD_MESSAGES] + intents: [Discord.Intents.GUILDS, Discord.Intents.GUILD_VOICE_STATES, Discord.Intents.GUILD_MESSAGES] }); const { DartVoiceManager } = require("dartjs"); const voiceManager = new DartVoiceManager(client); @@ -28,19 +31,19 @@ const ytdl = require("ytdl-core"); client.on("ready", () => console.log("Bot is online!")); client.on("messageCreate", message => { - if (message.author.bot) return; - - if (message.content === "!play") { - voiceManager.join(message.member.voice.channel) - .then(connection => { - const dispatcher = connection.play(ytdl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); - dispatcher.on("start", () => message.channel.send("Music started!")); - dispatcher.on("finish", () => { - connection.disconnect(); - message.channel.send("Music finished!"); - }); - }); - } + if (message.author.bot) return; + + if (message.content === "!play") { + voiceManager.join(message.member.voice.channel) + .then(connection => { + const dispatcher = connection.play(ytdl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")); + dispatcher.once("start", () => message.channel.send("Music started!")); + dispatcher.once("finish", () => { + connection.disconnect(); + message.channel.send("Music finished!"); + }); + }); + } }); client.login("XXX"); diff --git a/package.json b/package.json index dc51e79..544ed25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dartjs", - "version": "1.0.1", + "version": "1.1.0", "description": "Very simple framework that provides discord.js v12 voice interface", "main": "dist/index.js", "module": "dist/index.mjs", @@ -24,7 +24,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/DevSnowflake/dartjs.git" + "url": "git+https://github.com/CesiumLabs/dartjs.git" }, "keywords": [ "dartjs", @@ -35,15 +35,15 @@ "author": "DevAndromeda", "license": "MIT", "bugs": { - "url": "https://github.com/DevSnowflake/dartjs/issues" + "url": "https://github.com/CesiumLabs/dartjs/issues" }, - "homepage": "https://github.com/DevSnowflake/typescript-template#readme", + "homepage": "https://github.com/CesiumLabs/typescript-template#readme", "devDependencies": { "@favware/rollup-type-bundler": "^1.0.3", "@types/node": "^16.7.1", "@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/parser": "^4.29.2", - "discord.js": "^13.1.0", + "discord.js": "^13.6.0", "eslint": "^7.32.0", "gen-esm-wrapper": "^1.1.2", "husky": "^7.0.1", @@ -53,9 +53,10 @@ "ts-node": "^10.2.1", "tweetnacl": "^1.0.3", "typescript": "^4.3.5", - "ytdl-core": "^4.9.1" + "youtube-sr": "^4.1.13", + "ytdl-core": "^4.10.1" }, "dependencies": { - "@discordjs/voice": "^0.6.0" + "@discordjs/voice": "^0.8.0" } } diff --git a/src/core/DartVoiceManager.ts b/src/core/DartVoiceManager.ts index 0ff2e2c..ef6864d 100644 --- a/src/core/DartVoiceManager.ts +++ b/src/core/DartVoiceManager.ts @@ -1,13 +1,13 @@ import type { Client, Snowflake, GuildVoiceChannelResolvable, VoiceBasedChannelTypes } from "discord.js"; import { Collection } from "discord.js"; import VoiceConnection from "./VoiceConnection"; -import { VoiceChannels } from "../types/types"; +import { VoiceChannels, VoiceJoinConfig } from "../types/types"; export default class DartVoiceManager { public connections = new Collection(); public constructor(public readonly client: Client) {} - public async join(channel: GuildVoiceChannelResolvable) { + public async join(channel: GuildVoiceChannelResolvable, options?: VoiceJoinConfig) { const vc = this.client.channels.resolve(channel ?? ""); if (!vc || !vc.isVoice()) throw new Error("Voice channel was not provided!"); @@ -23,7 +23,7 @@ export default class DartVoiceManager { return connection; } } else { - const connection = await VoiceConnection.createConnection(vc, this); + const connection = await VoiceConnection.createConnection(vc, this, options); this.connections.set(vc.guildId, connection); return connection; diff --git a/src/core/StreamDispatcher.ts b/src/core/StreamDispatcher.ts index eb11e71..603362a 100644 --- a/src/core/StreamDispatcher.ts +++ b/src/core/StreamDispatcher.ts @@ -10,69 +10,90 @@ export default class StreamDispatcher extends EventEmitter { public audioResource: AudioResource = null; private readyLock = false; - constructor(public readonly connection: VoiceConnection) { + public constructor(public readonly connection: VoiceConnection) { super(); this.attachEvents(); this.connection.voice.subscribe(this.audioPlayer); } + public cleanUp() { + this.connection.voice.removeAllListeners("stateChange"); + this.connection.voice.removeAllListeners("debug"); + this.connection.voice.removeAllListeners("error"); + this.audioPlayer.removeAllListeners("stateChange"); + this.audioPlayer.removeAllListeners("error"); + } + private attachEvents() { - this.connection.voice.on("stateChange", async (_, newState) => { - if (newState.status === VoiceConnectionStatus.Disconnected) { - if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) { + if (!this.connection.voice.eventNames().includes("stateChange")) + this.connection.voice.on("stateChange", async (_, newState) => { + if (newState.status === VoiceConnectionStatus.Disconnected) { + if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) { + try { + await entersState(this.connection.voice, VoiceConnectionStatus.Connecting, 5_000); + } catch { + this.connection.voiceManager.connections.delete(this.connection.channel.guildId); + this.connection.emit("disconnect"); + this.connection.voice.destroy(); + } + } else if (this.connection.voice.rejoinAttempts < 5) { + await wait((this.connection.voice.rejoinAttempts + 1) * 5_000); + this.connection.voice.rejoin(); + } else { + this.connection.voiceManager.connections.delete(this.connection.channel.guildId); + this.connection.emit("disconnect"); + this.connection.voice.destroy(); + } + } else if (newState.status === VoiceConnectionStatus.Destroyed) { + this.audioPlayer?.stop(); + } else if (!this.readyLock && (newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)) { + this.readyLock = true; try { - await entersState(this.connection.voice, VoiceConnectionStatus.Connecting, 5_000); + await entersState(this.connection.voice, VoiceConnectionStatus.Ready, 20_000); } catch { - this.connection.manager.connections.delete(this.connection.channel.guildId); + if (this.connection.voice.state.status !== VoiceConnectionStatus.Destroyed) this.connection.voice.destroy(); + this.connection.voiceManager.connections.delete(this.connection.channel.guildId); this.connection.emit("disconnect"); - this.connection.voice.destroy(); + } finally { + this.readyLock = false; } - } else if (this.connection.voice.rejoinAttempts < 5) { - await wait((this.connection.voice.rejoinAttempts + 1) * 5_000); - this.connection.voice.rejoin(); - } else { - this.connection.manager.connections.delete(this.connection.channel.guildId); - this.connection.emit("disconnect"); - this.connection.voice.destroy(); } - } else if (newState.status === VoiceConnectionStatus.Destroyed) { - this.audioPlayer?.stop(); - } else if (!this.readyLock && (newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)) { - this.readyLock = true; - try { - await entersState(this.connection.voice, VoiceConnectionStatus.Ready, 20_000); - } catch { - if (this.connection.voice.state.status !== VoiceConnectionStatus.Destroyed) this.connection.voice.destroy(); - this.connection.manager.connections.delete(this.connection.channel.guildId); - this.connection.emit("disconnect"); - } finally { - this.readyLock = false; + }); + + if (!this.audioPlayer.eventNames().includes("stateChange")) + this.audioPlayer.on("stateChange", (oldState, newState) => { + if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { + this.emit("finish"); + } else if (newState.status === AudioPlayerStatus.Playing && oldState.status === AudioPlayerStatus.Buffering) { + this.emit("start"); } - } - }); + }); - this.audioPlayer.on("stateChange", (oldState, newState) => { - if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) { - this.emit("finish"); - } else if (newState.status === AudioPlayerStatus.Playing) { - this.emit("start"); - } - }); + if (!this.connection.voice.eventNames().includes("debug")) this.connection.voice.on("debug", (m) => void this.connection.emit("debug", m)); + if (!this.connection.voice.eventNames().includes("error")) this.connection.voice.on("error", (error) => void this.connection.emit("error", error)); + if (!this.audioPlayer.eventNames().includes("debug")) this.audioPlayer.on("debug", (m) => void this.emit("debug", m)); + if (!this.audioPlayer.eventNames().includes("error")) this.audioPlayer.on("error", (error) => void this.emit("error", error)); + } + + public end(force = false) { + this.audioPlayer.stop(force); + } - this.audioPlayer.on("debug", (m) => void this.emit("debug", m)); - this.audioPlayer.on("error", (error) => void this.emit("error", error)); + public stop(force = false) { + this.end(force); } - playStream(stream: Readable | string, options?: PlayOptions) { + public playStream(stream: Readable | string, options?: PlayOptions) { this.audioResource = createAudioResource(stream, { inputType: (options?.type as StreamType) || StreamType.Arbitrary, inlineVolume: options?.inlineVolume ?? true }); + this.end(true); this.audioPlayer.play(this.audioResource); } - setVolume(amount: number) { + public setVolume(amount: number) { const lastVolume = this.volume; if (lastVolume === amount || !this.audioResource?.volume) return false; this.audioResource?.volume?.setVolume(amount); @@ -80,7 +101,7 @@ export default class StreamDispatcher extends EventEmitter { return true; } - setVolumeLogarithmic(amount: number) { + public setVolumeLogarithmic(amount: number) { const lastVolume = this.volume; if (lastVolume === amount || !this.audioResource?.volume) return false; this.audioResource?.volume?.setVolumeLogarithmic(amount); @@ -88,7 +109,7 @@ export default class StreamDispatcher extends EventEmitter { return true; } - setVolumeDecibels(amount: number) { + public setVolumeDecibels(amount: number) { const lastVolume = this.volume; if (lastVolume === amount || !this.audioResource?.volume) return false; this.audioResource?.volume?.setVolumeDecibels(amount); @@ -96,39 +117,39 @@ export default class StreamDispatcher extends EventEmitter { return true; } - get volume() { + public get volume() { return this.audioResource?.volume?.volume ?? 1; } - get volumeDecibels() { + public get volumeDecibels() { return this.audioResource?.volume?.volumeDecibels ?? 1; } - get volumeLogarithmic() { + public get volumeLogarithmic() { return this.audioResource?.volume?.volumeLogarithmic ?? 1; } - get volumeEditable() { + public get volumeEditable() { return Boolean(this.audioResource?.volume); } - get streamTime() { + public get streamTime() { return this.audioResource?.playbackDuration ?? 0; } - get totalStreamTime() { + public get totalStreamTime() { return this.audioPlayer?.state.status === AudioPlayerStatus.Playing ? this.audioPlayer?.state.playbackDuration : 0; } - get paused() { + public get paused() { return this.audioPlayer.state.status === AudioPlayerStatus.Paused || this.audioPlayer.state.status === AudioPlayerStatus.AutoPaused; } - pause(silence = false) { + public pause(silence = false) { this.audioPlayer?.pause(silence); } - resume() { + public resume() { this.audioPlayer?.unpause(); } } diff --git a/src/core/VoiceConnection.ts b/src/core/VoiceConnection.ts index 5f1f532..4342b9b 100644 --- a/src/core/VoiceConnection.ts +++ b/src/core/VoiceConnection.ts @@ -1,33 +1,42 @@ -import { joinVoiceChannel, entersState, VoiceConnectionStatus, VoiceConnection as VoiceConnectionNative } from "@discordjs/voice"; +import { joinVoiceChannel, entersState, VoiceConnectionStatus, VoiceConnection as VoiceConnectionNative, DiscordGatewayAdapterCreator } from "@discordjs/voice"; import { TypedEmitter as EventEmitter } from "tiny-typed-emitter"; -import { VoiceChannels, VoiceEvents, VoiceConnectionData, PlayOptions } from "../types/types"; +import { VoiceChannels, VoiceEvents, VoiceConnectionData, PlayOptions, VoiceJoinConfig } from "../types/types"; import { catchError } from "../Utils/Util"; import type { Readable } from "stream"; import StreamDispatcher from "./StreamDispatcher"; import type { DartVoiceManager } from "./DartVoiceManager"; +import { VoiceReceiver } from "./VoiceReceiver"; export default class VoiceConnection extends EventEmitter { public readonly client = this.options.channel.client; public readonly channel = this.options.channel; public dispatcher: StreamDispatcher = null; - public readonly manager = this.options.manager; + public readonly voiceManager = this.options.manager; + public receiver = new VoiceReceiver(this.client, this); public constructor(public voice: VoiceConnectionNative, public readonly options: VoiceConnectionData) { super(); + } - this.voice.on("debug", (m) => void this.emit("debug", m)); - this.voice.on("error", (err) => void this.emit("error", err)); + public get audioPlayer() { + return this.dispatcher?.audioPlayer; } public disconnect() { - this.manager.connections.delete(this.channel.guildId); - this.voice.destroy(); + try { + this.voiceManager.connections.delete(this.channel.guildId); + this.dispatcher.removeAllListeners(); + this.receiver.removeAllListeners(); + this.voice.destroy(); + } catch { + /* noop */ + } } - public static createConnection(channel: VoiceChannels, manager: DartVoiceManager): Promise { + public static createConnection(channel: VoiceChannels, manager: DartVoiceManager, options?: VoiceJoinConfig): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { - const [error, connection] = await catchError(() => VoiceConnection.joinChannel(channel)); + const [error, connection] = await catchError(() => VoiceConnection.joinChannel(channel, options)); if (error) return reject(error); const vc = new VoiceConnection(connection, { channel, @@ -37,17 +46,20 @@ export default class VoiceConnection extends EventEmitter { }); } - public static joinChannel(channel: VoiceChannels): Promise { + public static joinChannel(channel: VoiceChannels, options?: VoiceJoinConfig): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { - const rawconnection = joinVoiceChannel({ + const rawConnection = joinVoiceChannel({ guildId: channel.guild.id, channelId: channel.id, - adapterCreator: channel.guild.voiceAdapterCreator + adapterCreator: channel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator, + selfDeaf: !!options?.selfDeaf, + selfMute: !!options?.selfMute, + group: options?.group }); const [error, connection] = await catchError(() => { - return entersState(rawconnection, VoiceConnectionStatus.Ready, 30_000); + return entersState(rawConnection, VoiceConnectionStatus.Ready, 30_000); }); if (error) return reject(error); resolve(connection); @@ -59,14 +71,17 @@ export default class VoiceConnection extends EventEmitter { const dispatcher = new StreamDispatcher(this); this.dispatcher = dispatcher; } - this.dispatcher.playStream(stream, options); return this.dispatcher; } - get status() { + public get status() { return this.voice.state.status; } + + public get ping() { + return this.voice.ping; + } } export { VoiceConnection }; diff --git a/src/core/VoiceReceiver.ts b/src/core/VoiceReceiver.ts new file mode 100644 index 0000000..140e716 --- /dev/null +++ b/src/core/VoiceReceiver.ts @@ -0,0 +1,59 @@ +import { EndBehaviorType } from "@discordjs/voice"; +import { Client, UserResolvable } from "discord.js"; +import { TypedEmitter as EventEmitter } from "tiny-typed-emitter"; +import { ReceiveStreamOptions, VoiceReceiverEvents } from "../types/types"; +import VoiceConnection from "./VoiceConnection"; +import prism from "prism-media"; +import { PassThrough, Readable } from "stream"; + +export class VoiceReceiver extends EventEmitter { + constructor(public readonly client: Client, public readonly connection: VoiceConnection) { + super(); + } + + public cleanUp() { + this.connection.voice.receiver.speaking.removeAllListeners("start"); + this.connection.voice.receiver.speaking.removeAllListeners("end"); + } + + public createStream(user: UserResolvable, options: ReceiveStreamOptions = {}) { + const _user = this.client.users.resolveId(user); + options ??= { end: "silence", mode: "opus" }; + + const passThrough = new PassThrough(); + const receiver = this.connection.voice?.receiver; + + if (!receiver.speaking.eventNames().includes("end")) + receiver.speaking.on("end", (userId) => { + this.emit("debug", `${userId} stopped speaking!`); + }); + + receiver.speaking.on("start", (userId) => { + if (userId === _user) { + const opusStream = receiver.subscribe(_user, { + end: { + behavior: options.end === "silence" ? EndBehaviorType.AfterSilence : EndBehaviorType.Manual, + duration: 100 + } + }); + + setImmediate(() => { + if (options.mode === "pcm") { + const pcmStream = new prism.opus.Decoder({ + channels: 2, + frameSize: 960, + rate: 48000 + }); + + opusStream.pipe(pcmStream); + return pcmStream.pipe(passThrough); + } else { + return opusStream.pipe(passThrough); + } + }); + } + }); + + return passThrough as Readable; + } +} diff --git a/src/index.ts b/src/index.ts index 7e9f6aa..0c6be14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { DartVoiceManager } from "./core/DartVoiceManager"; export { StreamDispatcher } from "./core/StreamDispatcher"; export { VoiceConnection } from "./core/VoiceConnection"; +export { VoiceReceiver } from "./core/VoiceReceiver"; export * from "./types/types"; export * from "./Utils/Util"; diff --git a/src/types/types.ts b/src/types/types.ts index cc53634..79016b7 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,4 +1,4 @@ -import { StreamType } from "@discordjs/voice"; +import { JoinConfig, StreamType } from "@discordjs/voice"; import type { VoiceChannel, StageChannel } from "discord.js"; import type { DartVoiceManager } from "../core/DartVoiceManager"; @@ -10,6 +10,10 @@ export interface VoiceEvents { disconnect: () => void; } +export interface VoiceReceiverEvents { + debug: (message: string) => void; +} + export interface DispatcherEvents { start: () => void; finish: () => void; @@ -24,6 +28,13 @@ export interface VoiceConnectionData { } export interface PlayOptions { - type?: `${StreamType}`; + type?: `${StreamType}` | StreamType; inlineVolume?: boolean; } + +export interface ReceiveStreamOptions { + mode?: "opus" | "pcm"; + end?: "silence" | "manual"; +} + +export type VoiceJoinConfig = Omit;