diff --git a/package.json b/package.json index c051458810..05235e6177 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "apps/*" ], "scripts": { + "bump": "node ./scripts/set-version.mjs", + "publish-packages": "yarn build && node ./scripts/publish.mjs", "bootstrap": "node ./scripts/bootstrap.js", "build": "turbo run build --color --filter=!website --filter=!music-bot", "docs": "node ./scripts/docgen.js", diff --git a/packages/adapter-local/README.md b/packages/adapter-local/README.md deleted file mode 100644 index 9a301abd93..0000000000 --- a/packages/adapter-local/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# `@discord-player/adapter-local` - -Local PlayerNode adapter that provides interface to Discord Player - -## Installation - -```sh -$ yarn add @discord-player/adapter-local -``` \ No newline at end of file diff --git a/packages/adapter-local/package.json b/packages/adapter-local/package.json deleted file mode 100644 index ec8097e0c3..0000000000 --- a/packages/adapter-local/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@discord-player/adapter-local", - "version": "0.1.0", - "description": "Discord Player local adapter", - "keywords": [ - "discord-player" - ], - "author": "twlite", - "homepage": "https://discord-player.js.org", - "license": "MIT", - "main": "dist/index.js", - "files": [ - "dist" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/Androz2091/discord-player.git" - }, - "scripts": { - "build:check": "tsc --noEmit", - "build": "tsup" - }, - "bugs": { - "url": "https://github.com/Androz2091/discord-player/issues" - }, - "devDependencies": { - "@discord-player/tsconfig": "workspace:^", - "tsup": "^7.2.0", - "typescript": "^5.2.2", - "vitest": "^0.34.6" - } -} diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts deleted file mode 100644 index 6b08400873..0000000000 --- a/packages/adapter-local/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export {}; - -// eslint-disable-next-line @typescript-eslint/no-inferrable-types -export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/adapter-local/tsconfig.json b/packages/adapter-local/tsconfig.json deleted file mode 100644 index 08981710e6..0000000000 --- a/packages/adapter-local/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "@discord-player/tsconfig/base.json", - "include": ["src/**/*"], - "exclude": ["node_modules"] -} \ No newline at end of file diff --git a/packages/adapter-remote/LICENSE b/packages/adapter-remote/LICENSE deleted file mode 100644 index fe07fc7364..0000000000 --- a/packages/adapter-remote/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Androz2091 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/adapter-remote/README.md b/packages/adapter-remote/README.md deleted file mode 100644 index 5661e3ca76..0000000000 --- a/packages/adapter-remote/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# `@discord-player/adapter-remote` - -Remote PlayerNode adapter that provides interface to Discord Player - -## Installation - -```sh -$ yarn add @discord-player/adapter-remote -``` \ No newline at end of file diff --git a/packages/adapter-remote/package.json b/packages/adapter-remote/package.json deleted file mode 100644 index a66b7156e6..0000000000 --- a/packages/adapter-remote/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@discord-player/adapter-remote", - "version": "0.1.0", - "description": "Discord Player remote adapter", - "keywords": [ - "discord-player" - ], - "author": "twlite", - "homepage": "https://discord-player.js.org", - "license": "MIT", - "main": "dist/index.js", - "directories": { - "src": "src" - }, - "files": [ - "dist" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/Androz2091/discord-player.git" - }, - "scripts": { - "build:check": "tsc --noEmit", - "build": "tsup" - }, - "bugs": { - "url": "https://github.com/Androz2091/discord-player/issues" - }, - "devDependencies": { - "@discord-player/tsconfig": "workspace:^", - "tsup": "^7.2.0", - "typescript": "^5.2.2", - "vitest": "^0.34.6" - } -} diff --git a/packages/adapter-remote/src/index.ts b/packages/adapter-remote/src/index.ts deleted file mode 100644 index 6b08400873..0000000000 --- a/packages/adapter-remote/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export {}; - -// eslint-disable-next-line @typescript-eslint/no-inferrable-types -export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/adapter-remote/tsconfig.json b/packages/adapter-remote/tsconfig.json deleted file mode 100644 index 08981710e6..0000000000 --- a/packages/adapter-remote/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "@discord-player/tsconfig/base.json", - "include": ["src/**/*"], - "exclude": ["node_modules"] -} \ No newline at end of file diff --git a/packages/adapter-remote/tsup.config.ts b/packages/adapter-remote/tsup.config.ts deleted file mode 100644 index e161ba4d4d..0000000000 --- a/packages/adapter-remote/tsup.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from '../../tsup.config'; -import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; - -export default defineConfig({ - esbuildPlugins: [esbuildPluginVersionInjector()] -}); diff --git a/packages/core/LICENSE b/packages/core/LICENSE deleted file mode 100644 index fe07fc7364..0000000000 --- a/packages/core/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Androz2091 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md deleted file mode 100644 index 9113d96ad5..0000000000 --- a/packages/core/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# `@discord-player/core` - -Discord Player core components - -## Installation - -```sh -$ yarn add @discord-player/core -``` - -This library is internally used by `discord-player`. This library handles all the work related to voice and provides a way to communicate with nodes. \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json deleted file mode 100644 index b0f242743f..0000000000 --- a/packages/core/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@discord-player/core", - "version": "0.1.0", - "description": "Discord Player core components", - "keywords": [ - "discord-player" - ], - "author": "Androz2091 ", - "homepage": "https://discord-player.js.org", - "license": "MIT", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "directories": { - "dist": "dist", - "src": "src" - }, - "files": [ - "dist" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/Androz2091/discord-player.git" - }, - "scripts": { - "build": "tsup", - "build:check": "tsc --noEmit" - }, - "bugs": { - "url": "https://github.com/Androz2091/discord-player/issues" - }, - "dependencies": { - "@discord-player/utils": "workspace:^", - "discord-api-types": "^0.37.2", - "discord-voip": "^0.1.2" - }, - "devDependencies": { - "@discord-player/tsconfig": "workspace:^", - "tsup": "^7.2.0", - "typescript": "^5.2.2", - "vitest": "^0.34.6" - } -} diff --git a/packages/core/src/classes/PlayerNodeManager.ts b/packages/core/src/classes/PlayerNodeManager.ts deleted file mode 100644 index 620dd819c8..0000000000 --- a/packages/core/src/classes/PlayerNodeManager.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { cpus } from 'node:os'; -import { Worker } from 'node:worker_threads'; -import { join } from 'node:path'; -import { Collection, EventEmitter } from '@discord-player/utils'; -import { WorkerEvents, WorkerOp } from '../utils/enums'; - -interface PlayerNodeConfig { - max?: number | 'auto'; - respawn?: boolean; -} - -interface BasicSubscription { - guild_id: string; - client_id: string; -} - -type WorkerResolvable = number | Worker; - -export interface PlayerNodeEvents { - error: (worker: Worker, error: Error) => Awaited; - message: (worker: Worker, message: unknown) => Awaited; - spawn: (worker: Worker) => Awaited; - debug: (message: string) => Awaited; - voiceStateUpdate: (worker: Worker, payload: any) => Awaited; - subscriptionCreate: (worker: Worker, payload: BasicSubscription) => Awaited; - subscriptionDelete: (worker: Worker, payload: BasicSubscription) => Awaited; -} - -export interface ServicePayload { - op: keyof typeof WorkerOp; - d: { - guild_id: string; - client_id: string; - } & T; -} - -export interface WorkerPayload { - t: keyof typeof WorkerEvents; - d: T; -} - -export class PlayerNodeManager extends EventEmitter { - public workers = new Collection(); - public constructor(public config: PlayerNodeConfig) { - super(); - } - - #debug(message: string) { - this.emit('debug', `[${this.constructor.name} | ${new Date().toLocaleString()}] ${message}`); - } - - public get maxThreads() { - const conf = this.config.max; - if (conf === 'auto') return cpus().length; - if (typeof conf !== 'number' || Number.isNaN(conf) || conf < 1 || !Number.isFinite(conf)) return 1; - return conf; - } - - public get spawnable() { - return this.workers.size < this.maxThreads; - } - - // TODO - public getLeastBusy() { - return; - } - - public send(workerRes: WorkerResolvable, data: ServicePayload) { - const worker = this.resolveWorker(workerRes); - if (!worker) throw new Error('Worker does not exist'); - this.#debug(`Sending ${JSON.stringify(data)} to thread ${worker.threadId}`); - worker.postMessage(data); - } - - public spawn() { - return new Promise((resolve) => { - if (!this.spawnable) return resolve(this.workers.random()!); - - const worker = new Worker(join(__dirname, '..', 'worker', 'worker.js')); - this.#debug(`Spawned worker at thread ${worker.threadId}`); - - worker.on('online', () => { - this.#debug(`worker ${worker.threadId} is online`); - this.workers.set(worker.threadId, worker); - this.emit('spawn', worker); - return resolve(worker); - }); - - worker.on('message', (message: WorkerPayload) => { - this.#debug(`Incoming message from worker ${worker.threadId}\n\n${JSON.stringify(message)}`); - switch (message.t) { - case WorkerEvents.VOICE_STATE_UPDATE: { - return this.emit('voiceStateUpdate', worker, message.d); - } - case WorkerEvents.ERROR: { - return this.emit('error', worker, new Error((message.d as any).message)); - } - case WorkerEvents.SUBSCRIPTION_CREATE: { - return this.emit('subscriptionCreate', worker, message.d as BasicSubscription); - } - case WorkerEvents.SUBSCRIPTION_DELETE: { - return this.emit('subscriptionDelete', worker, message.d as BasicSubscription); - } - default: { - return this.emit('message', worker, message); - } - } - }); - - worker.on('exit', () => { - this.#debug(`Worker terminated at thread ${worker.threadId}`); - this.workers.delete(worker.threadId); - }); - - worker.on('error', (error) => { - this.#debug(`Incoming error message from worker ${worker.threadId}\n\n${JSON.stringify(error)}`); - this.emit('error', worker, error); - }); - }); - } - - public resolveWorker(worker: WorkerResolvable) { - if (typeof worker === 'number') return this.workers.get(worker); - return this.workers.find((res) => res.threadId === worker.threadId); - } - - public async terminate(worker?: WorkerResolvable) { - if (worker) { - const internalWorker = this.resolveWorker(worker); - if (internalWorker) { - this.#debug(`Terminating worker ${internalWorker.threadId}...`); - await internalWorker.terminate(); - this.workers.delete(internalWorker.threadId); - } - } else { - for (const [id, thread] of this.workers) { - this.#debug(`Terminating worker ${thread.threadId}...`); - await thread.terminate(); - this.workers.delete(id); - } - } - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts deleted file mode 100644 index fb547a9361..0000000000 --- a/packages/core/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './utils/enums'; -export * from './classes/PlayerNodeManager'; - -// eslint-disable-next-line @typescript-eslint/no-inferrable-types -export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/core/src/utils/clients.ts b/packages/core/src/utils/clients.ts deleted file mode 100644 index 6f28f3eebb..0000000000 --- a/packages/core/src/utils/clients.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Collection } from '@discord-player/utils'; -import type { SubscriptionClient } from '../worker/SubscriptionClient'; - -export const clients = new Collection(); diff --git a/packages/core/src/utils/enums.ts b/packages/core/src/utils/enums.ts deleted file mode 100644 index 6b54380f86..0000000000 --- a/packages/core/src/utils/enums.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { keyMirror } from '@discord-player/utils'; - -// prettier-ignore -export const WorkerOp = keyMirror([ - "JOIN_VOICE_CHANNEL", - "CREATE_SUBSCRIPTION", - "DELETE_SUBSCRIPTION", - "GATEWAY_PAYLOAD", - "PLAY" -]); - -// prettier-ignore -export const WorkerEvents = keyMirror([ - "SUBSCRIPTION_CREATE", - "SUBSCRIPTION_DELETE", - "VOICE_STATE_UPDATE", - "ERROR", - "CONNECTION_DESTROY" -]); diff --git a/packages/core/src/worker/AudioNode.ts b/packages/core/src/worker/AudioNode.ts deleted file mode 100644 index d0bf05bfae..0000000000 --- a/packages/core/src/worker/AudioNode.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createAudioPlayer, createAudioResource, StreamType, VoiceConnection } from 'discord-voip'; - -export interface NodePlayerOptions { - query: string; - metadata: unknown; - initialVolume?: number; -} - -export class AudioNode { - public audioPlayer = createAudioPlayer(); - public constructor(public connection: VoiceConnection, public client: string) { - connection.subscribe(this.audioPlayer); - } - - public get guild() { - return this.connection.joinConfig.guildId; - } - - public get channel() { - return this.connection.joinConfig.channelId; - } - - public play(options: NodePlayerOptions) { - const resource = createAudioResource(options.query, { - inputType: StreamType.Arbitrary, - inlineVolume: typeof options.initialVolume === 'number', - metadata: options.metadata - }); - - if ('initialVolume' in options && resource.volume) { - resource.volume.setVolumeLogarithmic(options.initialVolume!); - } - - this.audioPlayer.play(resource); - } - - public destroy() { - this.connection.destroy(); - } -} diff --git a/packages/core/src/worker/SubscriptionClient.ts b/packages/core/src/worker/SubscriptionClient.ts deleted file mode 100644 index febeaf9e00..0000000000 --- a/packages/core/src/worker/SubscriptionClient.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Collection } from '@discord-player/utils'; -import { DiscordGatewayAdapterLibraryMethods, joinVoiceChannel } from 'discord-voip'; -import { WorkerEvents } from '../utils/enums'; -import { AudioNode } from './AudioNode'; -import { notify } from './notifier'; - -export interface SubscriptionPayload { - channelId: string; - guildId: string; - deafen?: boolean; -} - -export class SubscriptionClient { - public subscriptions = new Collection(); - public adapters = new Collection(); - public constructor(public clientId: string) {} - - public connect(config: SubscriptionPayload) { - const voiceConnection = joinVoiceChannel({ - channelId: config.channelId, - guildId: config.guildId, - selfDeaf: Boolean(config.deafen), - adapterCreator: (adapter) => { - this.adapters.set(config.guildId, adapter); - return { - sendPayload: (payload) => { - notify({ - t: WorkerEvents.VOICE_STATE_UPDATE, - d: payload - }); - return true; - }, - destroy: () => { - this.adapters.delete(config.guildId); - this.subscriptions.delete(config.guildId); - notify({ - t: WorkerEvents.CONNECTION_DESTROY, - d: { - client_id: this.clientId, - guild_id: config.guildId, - channel_id: config.channelId - } - }); - } - }; - } - }); - - this.subscriptions.set(voiceConnection.joinConfig.guildId, new AudioNode(voiceConnection, this.clientId)); - } - - public disconnect(config: Pick) { - const node = this.subscriptions.get(config.guildId); - if (node) { - node.connection.destroy(); - this.subscriptions.delete(config.guildId); - } - } - - public disconnectAll() { - for (const [id, node] of this.subscriptions) { - node.connection.destroy(); - this.subscriptions.delete(id); - } - } -} diff --git a/packages/core/src/worker/actions/CREATE_SUBSCRIPTION.ts b/packages/core/src/worker/actions/CREATE_SUBSCRIPTION.ts deleted file mode 100644 index 30d2c75d69..0000000000 --- a/packages/core/src/worker/actions/CREATE_SUBSCRIPTION.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ServicePayload } from '../../classes/PlayerNodeManager'; -import { WorkerEvents, WorkerOp } from '../../utils/enums'; -import { SubscriptionClient } from '../SubscriptionClient'; -import { BaseAction } from './base/BaseAction'; - -class CreateSubscription extends BaseAction { - public actionName = WorkerOp.CREATE_SUBSCRIPTION; - - public handle(data: ServicePayload) { - if (this.isSubscribed(data)) return; - const client = new SubscriptionClient(data.d.client_id); - this.setClient(data, client); - this.notify({ - t: WorkerEvents.SUBSCRIPTION_CREATE, - d: { - client_id: data.d.client_id, - guild_id: data.d.guild_id - } - }); - } -} - -export default new CreateSubscription(); diff --git a/packages/core/src/worker/actions/DELETE_SUBSCRIPTION.ts b/packages/core/src/worker/actions/DELETE_SUBSCRIPTION.ts deleted file mode 100644 index 28aa1fcfa2..0000000000 --- a/packages/core/src/worker/actions/DELETE_SUBSCRIPTION.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ServicePayload } from '../../classes/PlayerNodeManager'; -import { WorkerEvents, WorkerOp } from '../../utils/enums'; -import { BaseAction } from './base/BaseAction'; - -class DeleteSubscription extends BaseAction { - public actionName = WorkerOp.DELETE_SUBSCRIPTION; - - public handle(data: ServicePayload) { - const client = this.getClient(data); - if (client) { - client.disconnectAll(); - this.deleteClient(data); - this.notify({ - t: WorkerEvents.SUBSCRIPTION_DELETE, - d: { - client_id: client.clientId, - guild_id: data.d.guild_id - } - }); - } - } -} - -export default new DeleteSubscription(); diff --git a/packages/core/src/worker/actions/GATEWAY_PAYLOAD.ts b/packages/core/src/worker/actions/GATEWAY_PAYLOAD.ts deleted file mode 100644 index 3b75699b1f..0000000000 --- a/packages/core/src/worker/actions/GATEWAY_PAYLOAD.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ServicePayload } from '../../classes/PlayerNodeManager'; -import { WorkerOp } from '../../utils/enums'; -import { BaseAction } from './base/BaseAction'; -import { GatewayDispatchEvents } from 'discord-api-types/v10'; - -class JoinVoiceChannel extends BaseAction { - public actionName = WorkerOp.GATEWAY_PAYLOAD; - - public async handle(data: ServicePayload) { - const client = this.getClient(data); - if (!client) return; - const adapter = client.adapters.get(data.d.payload.d.guild_id); - if (!adapter) return; - const message = data.d.payload; - if (message.t === GatewayDispatchEvents.VoiceServerUpdate) { - adapter.onVoiceServerUpdate(message.d); - } else if (message.t === GatewayDispatchEvents.VoiceStateUpdate && message.d.session_id && message.d.user_id === client.clientId) { - adapter.onVoiceStateUpdate(message.d); - } - } -} - -export default new JoinVoiceChannel(); diff --git a/packages/core/src/worker/actions/JOIN_VOICE_CHANNEL.ts b/packages/core/src/worker/actions/JOIN_VOICE_CHANNEL.ts deleted file mode 100644 index 0c12a2ba65..0000000000 --- a/packages/core/src/worker/actions/JOIN_VOICE_CHANNEL.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ServicePayload } from '../../classes/PlayerNodeManager'; -import { WorkerOp } from '../../utils/enums'; -import { BaseAction } from './base/BaseAction'; - -export interface JoinPayload { - channel_id: string; - self_deaf?: boolean; -} - -class JoinVoiceChannel extends BaseAction { - public actionName = WorkerOp.JOIN_VOICE_CHANNEL; - - public async handle(data: ServicePayload) { - const client = this.getClient(data); - if (client) - await client.connect({ - channelId: data.d.channel_id, - guildId: data.d.guild_id, - deafen: data.d.self_deaf - }); - } -} - -export default new JoinVoiceChannel(); diff --git a/packages/core/src/worker/actions/PLAY.ts b/packages/core/src/worker/actions/PLAY.ts deleted file mode 100644 index 7760d14836..0000000000 --- a/packages/core/src/worker/actions/PLAY.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ServicePayload } from '../../classes/PlayerNodeManager'; -import { WorkerOp } from '../../utils/enums'; -import { BaseAction } from './base/BaseAction'; - -export interface PlayPayload { - query: string; - metadata: unknown; - initial_volume?: number; -} - -class Play extends BaseAction { - public actionName = WorkerOp.PLAY; - - public async handle(data: ServicePayload) { - const client = this.getClient(data); - if (!client) return; - const node = client.subscriptions.get(data.d.guild_id); - if (node) { - const { query, metadata, initial_volume } = data.d; - node.play({ query, metadata, initialVolume: initial_volume }); - } - } -} - -export default new Play(); diff --git a/packages/core/src/worker/actions/base/BaseAction.ts b/packages/core/src/worker/actions/base/BaseAction.ts deleted file mode 100644 index 447df3a2af..0000000000 --- a/packages/core/src/worker/actions/base/BaseAction.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ServicePayload, WorkerPayload } from '../../../classes/PlayerNodeManager'; -import { clients } from '../../../utils/clients'; -import { WorkerOp } from '../../../utils/enums'; -import { notify } from '../../notifier'; -import { SubscriptionClient } from '../../SubscriptionClient'; - -export class BaseAction { - public clients = clients; - public actionName!: keyof typeof WorkerOp; - - public getClient(data: ServicePayload) { - return this.clients.get(data.d.client_id); - } - - public setClient(data: ServicePayload, client: SubscriptionClient) { - return this.clients.set(data.d.client_id, client); - } - - public deleteClient(data: ServicePayload) { - return this.clients.delete(data.d.client_id); - } - - public isSubscribed(data: ServicePayload) { - return this.clients.has(data.d.client_id); - } - - public handle(data: ServicePayload) {} - - public notify(data: WorkerPayload) { - notify(data); - } -} diff --git a/packages/core/src/worker/notifier.ts b/packages/core/src/worker/notifier.ts deleted file mode 100644 index cfdde3ee3b..0000000000 --- a/packages/core/src/worker/notifier.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { parentPort } from 'node:worker_threads'; -import { WorkerPayload } from '../classes/PlayerNodeManager'; - -export function notify(data: WorkerPayload) { - parentPort?.postMessage(data); -} diff --git a/packages/core/src/worker/worker.ts b/packages/core/src/worker/worker.ts deleted file mode 100644 index 45d18c8b26..0000000000 --- a/packages/core/src/worker/worker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ServicePayload } from '../classes/PlayerNodeManager'; -import { WorkerEvents } from '../utils/enums'; -import type { BaseAction } from './actions/base/BaseAction'; -import { notify } from './notifier'; -import { parentPort } from 'node:worker_threads'; - -parentPort?.on('message', async (message: ServicePayload) => { - const action = getAction(message.op); - if (action) { - try { - return void (await action.handle(message)); - } catch (e) { - return notify({ - t: WorkerEvents.ERROR, - d: { - message: `${(e as any).stack || e}` - } - }); - } - } -}); - -function getAction(op: string) { - try { - const action = require(`${__dirname}/actions/${op}`); - return (action.default || action) as BaseAction; - } catch { - return null; - } -} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json deleted file mode 100644 index f19c9a585f..0000000000 --- a/packages/core/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@discord-player/tsconfig/base.json", - "include": ["src/**/*"], - "compilerOptions": { - "esModuleInterop": true - } -} \ No newline at end of file diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts deleted file mode 100644 index e161ba4d4d..0000000000 --- a/packages/core/tsup.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from '../../tsup.config'; -import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; - -export default defineConfig({ - esbuildPlugins: [esbuildPluginVersionInjector()] -}); diff --git a/packages/discord-player/package.json b/packages/discord-player/package.json index c31ea2426c..18e36ce0f2 100644 --- a/packages/discord-player/package.json +++ b/packages/discord-player/package.json @@ -1,6 +1,6 @@ { "name": "discord-player", - "version": "6.8.0-dev.1", + "version": "7.0.0-dev.1", "description": "Complete framework to facilitate music commands using discord.js", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -23,6 +23,9 @@ "type": "git", "url": "git+https://github.com/Androz2091/discord-player.git" }, + "publishConfig": { + "access": "public" + }, "keywords": [ "music", "player", @@ -54,7 +57,7 @@ "@discord-player/ffmpeg": "workspace:^", "@discord-player/utils": "workspace:^", "@web-scrobbler/metadata-filter": "^3.1.0", - "discord-voip": "^0.1.3", + "discord-voip": "workspace:^", "libsodium-wrappers": "^0.7.13" }, "peerDependencies": { @@ -69,6 +72,7 @@ "discord.js": "^14.15.3", "eris": "^0.17.2", "opusscript": "^0.0.8", + "prism-media": "^1.3.5", "tsup": "^7.2.0", "typescript": "^5.2.2", "vitest": "^0.34.6" diff --git a/packages/discord-player/src/Player.ts b/packages/discord-player/src/Player.ts index 7904a43e7e..3d8084271c 100644 --- a/packages/discord-player/src/Player.ts +++ b/packages/discord-player/src/Player.ts @@ -6,7 +6,7 @@ import { VoiceUtils } from './VoiceInterface/VoiceUtils'; import { PlayerEvents, QueryType, SearchOptions, PlayerInitOptions, PlaylistInitData, SearchQueryType, PlayerEvent } from './types/types'; import { QueryResolver, ResolvedQuery } from './utils/QueryResolver'; import { Util } from './utils/Util'; -import { generateDependencyReport, version as dVoiceVersion } from 'discord-voip'; +import { version as dVoiceVersion } from 'discord-voip'; import { ExtractorExecutionContext } from './extractors/ExtractorExecutionContext'; import { BaseExtractor } from './extractors/BaseExtractor'; import * as _internals from './utils/__internal__'; @@ -19,6 +19,7 @@ import { Context, createContext } from './hooks'; import { HooksCtx } from './hooks/common'; import { LrcLib } from './lrclib/LrcLib'; import { getCompatName, isClientProxy } from './compat/createErisCompat'; +import { DependencyReportGenerator } from './utils/DependencyReportGenerator'; const kSingleton = Symbol('InstanceDiscordPlayerSingleton'); @@ -671,10 +672,10 @@ export class Player extends PlayerEventsEmitter { `- Node version: ${process.version} (Detected Runtime: ${runtime}, Platform: ${process.platform} [${process.arch}])`, (() => { if (this.options.useLegacyFFmpeg) return '- ffmpeg: N/A (using legacy ffmpeg)'; - const info = FFmpeg.locateSafe(); + const info = FFmpeg.resolveSafe(); if (!info) return 'FFmpeg/Avconv not found'; - return [`- ffmpeg: ${info.version}`, `- command: ${info.command}`, `- static: ${info.isStatic}`, `- libopus: ${info.metadata!.includes('--enable-libopus')}`].join('\n'); + return [`- ffmpeg: ${info.version}`, `- command: ${info.command}`, `- static: ${info.module}`, `- libopus: ${info.result!.includes('--enable-libopus')}`].join('\n'); })(), '\n', 'Loaded Extractors:', @@ -685,7 +686,7 @@ export class Player extends PlayerEventsEmitter { }) .join('\n') || 'N/A', '\n\ndiscord-voip', - generateDependencyReport() + DependencyReportGenerator.generateString() ]; return depsReport.join('\n'); diff --git a/packages/discord-player/src/VoiceInterface/StreamDispatcher.ts b/packages/discord-player/src/VoiceInterface/StreamDispatcher.ts index 91a569e095..8ebbc9b6b9 100644 --- a/packages/discord-player/src/VoiceInterface/StreamDispatcher.ts +++ b/packages/discord-player/src/VoiceInterface/StreamDispatcher.ts @@ -18,7 +18,6 @@ import { Track } from '../fabric/Track'; import { Util } from '../utils/Util'; import { EqualizerBand, BiquadFilters, PCMFilters, FiltersChain } from '@discord-player/equalizer'; import { GuildQueue, GuildQueueEvent, PostProcessedResult } from '../queue'; -import { VoiceReceiverNode } from '../queue/VoiceReceiverNode'; import { Exceptions } from '../errors'; export interface CreateStreamOps { @@ -56,7 +55,6 @@ export interface VoiceEvents { class StreamDispatcher extends EventEmitter { public voiceConnection: VoiceConnection; public audioPlayer: AudioPlayer; - public receiver = new VoiceReceiverNode(this); public channel: VoiceChannel | StageChannel; public audioResource?: AudioResource | null; public dsp = new FiltersChain(); @@ -333,7 +331,9 @@ class StreamDispatcher extends EventEmitter { */ public destroy() { this.disconnect(); + // @ts-ignore this.audioPlayer.removeAllListeners(); + // @ts-ignore this.voiceConnection.removeAllListeners(); this.dsp.destroy(); this.audioResource = null; diff --git a/packages/discord-player/src/index.ts b/packages/discord-player/src/index.ts index 452aaf01e0..0fc8d8a92d 100644 --- a/packages/discord-player/src/index.ts +++ b/packages/discord-player/src/index.ts @@ -21,6 +21,7 @@ export * from './Player'; export * from './hooks'; export * from './utils/IPRotator'; export * from './utils/serde'; +export * from './utils/DependencyReportGenerator'; export { AudioFilters as PCMAudioFilters, type BiquadFilters, @@ -33,7 +34,7 @@ export { AF_VAPORWAVE_RATE, FiltersChain } from '@discord-player/equalizer'; -export { createAudioPlayer, AudioPlayer, getVoiceConnection, getVoiceConnections, type CreateAudioPlayerOptions } from 'discord-voip'; +export { createAudioPlayer, AudioPlayer, getVoiceConnection, getVoiceConnections, joinVoiceChannel, type JoinConfig, type JoinVoiceChannelOptions, type CreateAudioPlayerOptions } from 'discord-voip'; // eslint-disable-next-line @typescript-eslint/no-inferrable-types export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/discord-player/src/queue/GuildQueue.ts b/packages/discord-player/src/queue/GuildQueue.ts index 8b0cf6db4b..4c054624a7 100644 --- a/packages/discord-player/src/queue/GuildQueue.ts +++ b/packages/discord-player/src/queue/GuildQueue.ts @@ -495,13 +495,6 @@ export class GuildQueue { return Util.buildTimeCode(Util.parseMS(this.estimatedDuration)); } - /** - * The voice receiver for this queue - */ - public get voiceReceiver() { - return this.dispatcher?.receiver ?? null; - } - /** * The sync lyrics provider for this queue. * @example const lyrics = await player.lyrics.search({ q: 'Alan Walker Faded' }); diff --git a/packages/discord-player/src/queue/GuildQueuePlayerNode.ts b/packages/discord-player/src/queue/GuildQueuePlayerNode.ts index f399655aba..89699e8a78 100644 --- a/packages/discord-player/src/queue/GuildQueuePlayerNode.ts +++ b/packages/discord-player/src/queue/GuildQueuePlayerNode.ts @@ -11,8 +11,7 @@ import { Exceptions } from '../errors'; import { TypeUtil } from '../utils/TypeUtil'; import { CreateStreamOps } from '../VoiceInterface/StreamDispatcher'; import { ExtractorStreamable } from '../extractors/BaseExtractor'; -import * as prism from 'prism-media'; -import { OpusDecoder } from '@discord-player/opus'; +import { OggDemuxer, OpusDecoder, WebmDemuxer } from '@discord-player/opus'; export const FFMPEG_SRATE_REGEX = /asetrate=\d+\*(\d(\.\d)?)/; @@ -619,9 +618,9 @@ export class GuildQueuePlayerNode { stream : $fmt === StreamType.OggOpus ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream.pipe(new prism.opus.OggDemuxer() as any) : + stream.pipe(new OggDemuxer() as any) : // eslint-disable-next-line @typescript-eslint/no-explicit-any - stream.pipe(new prism.opus.WebmDemuxer() as any); + stream.pipe(new WebmDemuxer() as any); if (shouldPCM) { // if we have any filters enabled, we need to decode the opus stream to pcm diff --git a/packages/discord-player/src/queue/VoiceReceiverNode.ts b/packages/discord-player/src/queue/VoiceReceiverNode.ts deleted file mode 100644 index 66be592489..0000000000 --- a/packages/discord-player/src/queue/VoiceReceiverNode.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { UserResolvable } from 'discord.js'; -import { PassThrough, type Readable } from 'node:stream'; -import { EndBehaviorType } from 'discord-voip'; -import * as prism from 'prism-media'; -import { StreamDispatcher } from '../VoiceInterface/StreamDispatcher'; -import { Track } from '../fabric/Track'; -import { RawTrackData } from '../types/types'; -import { Exceptions } from '../errors'; - -export interface VoiceReceiverOptions { - mode?: 'opus' | 'pcm'; - end?: EndBehaviorType; - silenceDuration?: number; - crc?: boolean; -} - -export type RawTrackInit = Partial>; - -export class VoiceReceiverNode { - public constructor(public dispatcher: StreamDispatcher) {} - - public createRawTrack(stream: Readable, data: RawTrackInit = {}) { - data.title ??= `Recording ${Date.now()}`; - - return new Track(this.dispatcher.queue.player, { - author: 'Discord', - description: data.title, - title: data.title, - duration: data.duration || '0:00', - views: 0, - requestedBy: data.requestedBy, - thumbnail: data.thumbnail || 'https://cdn.discordapp.com/embed/avatars/0.png', - url: data.url || 'https://discord.com', - source: 'arbitrary', - raw: { - engine: stream, - source: 'arbitrary' - } - }); - } - - /** - * Merge multiple streams together - * @param streams The array of streams to merge - */ - public mergeRecordings(streams: Readable[]) { - // TODO - void streams; - throw Exceptions.ERR_NOT_IMPLEMENTED(`${this.constructor.name}.mergeRecordings()`); - } - - /** - * Record a user in voice channel - * @param user The user to record - * @param options Recording options - */ - public recordUser( - user: UserResolvable, - options: VoiceReceiverOptions = { - end: EndBehaviorType.AfterSilence, - mode: 'pcm', - silenceDuration: 1000 - } - ) { - const _user = this.dispatcher.queue.player.client.users.resolveId(user); - - const passThrough = new PassThrough(); - const receiver = this.dispatcher.voiceConnection.receiver; - - if (!receiver) throw Exceptions.ERR_NO_RECEIVER(); - - receiver.speaking.on('start', (userId) => { - if (userId === _user) { - const receiveStream = receiver.subscribe(_user, { - end: { - behavior: options.end || EndBehaviorType.AfterSilence, - duration: options.silenceDuration ?? 1000 - } - }); - - setImmediate(async () => { - if (options.mode === 'pcm') { - const pcm = receiveStream.pipe( - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (prism.opus || (prism).default.opus).Decoder({ - channels: 2, - frameSize: 960, - rate: 48000 - }) - ); - // @ts-ignore - return pcm.pipe(passThrough); - } else { - // @ts-ignore - return receiveStream.pipe(passThrough); - } - }).unref(); - } - }); - - return passThrough as Readable; - } -} diff --git a/packages/discord-player/src/queue/index.ts b/packages/discord-player/src/queue/index.ts index 1dee13e03c..a253eb1a90 100644 --- a/packages/discord-player/src/queue/index.ts +++ b/packages/discord-player/src/queue/index.ts @@ -3,5 +3,4 @@ export * from './GuildQueue'; export * from './GuildQueueAudioFilters'; export * from './GuildQueueHistory'; export * from './GuildQueuePlayerNode'; -export * from './VoiceReceiverNode'; export * from './GuildQueueStatistics'; diff --git a/packages/discord-player/src/utils/DependencyReportGenerator.ts b/packages/discord-player/src/utils/DependencyReportGenerator.ts new file mode 100644 index 0000000000..c69e8f0375 --- /dev/null +++ b/packages/discord-player/src/utils/DependencyReportGenerator.ts @@ -0,0 +1,167 @@ +import { resolve, dirname } from 'node:path'; +import { FFmpeg, FFmpegLib } from '@discord-player/ffmpeg'; + +export interface PackageJSON { + name: string; + version: string; +} + +export type MaybeNull = T | null; + +export interface DependenciesReport { + core: { + 'discord-player': string; + 'discord-voip': string; + }; + libopus: { + mediaplex: MaybeNull; + '@discordjs/opus': MaybeNull; + '@evan/opus': MaybeNull; + opusscript: MaybeNull; + 'node-opus': MaybeNull; + }; + libsodium: { + 'sodium-native': MaybeNull; + sodium: MaybeNull; + 'libsodium-wrappers': MaybeNull; + 'sodium-javascript': MaybeNull; + '@stablelib/xchacha20poly1305': MaybeNull; + }; + ffmpeg: FFmpegReport; +} + +export type FFmpegReport = Record< + FFmpegLib, + MaybeNull<{ + version: string; + hasLibopus: boolean; + }> +>; + +/** + * A utility to generate a report of the dependencies used by the discord-player module. + */ +export const DependencyReportGenerator = { + /** + * Finds the package.json file of a package. + * @param dir - The directory to start searching from + * @param packageName - The name of the package to find + * @param depth - The maximum depth to search + * @returns The package.json file, or null if not found + */ + findPackageJSON(dir: string, packageName: string, depth: number): PackageJSON | null { + if (depth === 0) return null; + + const target = resolve(dir, 'package.json'); + + const next = () => DependencyReportGenerator.findPackageJSON(resolve(dir, '..'), packageName, depth - 1); + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkgJSON: PackageJSON = require(target); + + if (pkgJSON.name !== packageName) { + return next(); + } + + return pkgJSON; + } catch { + return next(); + } + }, + /** + * Tries to find the version of a dependency. + * @param name - The package to find the version of + * @param maxLookupDepth - The maximum depth to search for the package.json file + * @returns The version of the package, or null if not found + */ + version(name: string, maxLookupDepth = 3): string | null { + try { + if (name === 'discord-player') { + return '[VI]{{inject}}[/VI]'; + } + + const pkg = DependencyReportGenerator.findPackageJSON(dirname(require.resolve(name)), name, maxLookupDepth); + return pkg?.version ?? null; + } catch { + return null; + } + }, + /** + * Generates a report of the dependencies used by the discord-player module. + * @returns The report object + */ + generate(): DependenciesReport { + const ffmpegReport = {} as FFmpegReport; + + for (const lib of FFmpeg.sources) { + ffmpegReport[lib.name] = null; + } + + const ffmpeg = FFmpeg.resolveSafe(); + + if (ffmpeg) { + ffmpegReport[ffmpeg.name] = { + hasLibopus: ffmpeg.command.includes('--enable-libopus'), + version: ffmpeg.version + }; + } + + return { + core: { + 'discord-player': DependencyReportGenerator.version('discord-player') as string, + 'discord-voip': DependencyReportGenerator.version('discord-voip') as string + }, + libopus: { + mediaplex: DependencyReportGenerator.version('mediaplex'), + '@discordjs/opus': DependencyReportGenerator.version('@discordjs/opus'), + '@evan/opus': DependencyReportGenerator.version('@evan/opus'), + opusscript: DependencyReportGenerator.version('opusscript'), + 'node-opus': DependencyReportGenerator.version('node-opus') + }, + libsodium: { + 'sodium-native': DependencyReportGenerator.version('sodium-native'), + sodium: DependencyReportGenerator.version('sodium'), + 'libsodium-wrappers': DependencyReportGenerator.version('libsodium-wrappers'), + '@stablelib/xchacha20poly1305': DependencyReportGenerator.version('@stablelib/xchacha20poly1305'), + 'sodium-javascript': DependencyReportGenerator.version('sodium-javascript') + }, + ffmpeg: ffmpegReport + }; + }, + /** + * Generates a string representation of the dependencies report. + * @returns The string representation + */ + generateString(): string { + const report = DependencyReportGenerator.generate(); + const line = '-'.repeat(50); + + const output: string[] = []; + + output.push('Dependencies Report'); + output.push(line); + + const keys = Object.keys(report) as (keyof DependenciesReport)[]; + + for (const _key of keys) { + const key = _key as keyof DependenciesReport; + + output.push(key); + + const subKeys = Object.keys(report[key]); + + for (const _subKey of subKeys) { + const subKey = _subKey as keyof DependenciesReport[typeof key]; + + output.push(`- ${subKey}: ${report[key][subKey] ?? 'N/A'}`); + } + + output.push(''); + } + + output.push(line); + + return output.join('\n'); + } +}; diff --git a/packages/discord-player/src/utils/FFmpegStream.ts b/packages/discord-player/src/utils/FFmpegStream.ts index 846c463c40..df9c05ab64 100644 --- a/packages/discord-player/src/utils/FFmpegStream.ts +++ b/packages/discord-player/src/utils/FFmpegStream.ts @@ -1,7 +1,9 @@ import type { Duplex, Readable } from 'stream'; -import * as prism from 'prism-media'; import { FFmpeg } from '@discord-player/ffmpeg'; +// @ts-ignore +import * as prism from 'prism-media'; + export interface FFmpegStreamOptions { fmt?: string; encoderArgs?: string[]; @@ -67,13 +69,14 @@ export function createFFmpegStream(stream: Readable | Duplex | string, options?: const FFMPEG = getFFmpegProvider(!!options.useLegacyFFmpeg); - const transcoder = new FFMPEG({ shell: false, args }); + const transcoder: Duplex = new FFMPEG({ shell: false, args }); transcoder.on('close', () => transcoder.destroy()); if (typeof stream !== 'string') { stream.on('error', () => transcoder.destroy()); - stream.pipe(transcoder); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream.pipe(transcoder as any); } return transcoder; diff --git a/packages/adapter-local/LICENSE b/packages/discord-voip/LICENSE similarity index 95% rename from packages/adapter-local/LICENSE rename to packages/discord-voip/LICENSE index fe07fc7364..c7e9a01d7a 100644 --- a/packages/adapter-local/LICENSE +++ b/packages/discord-voip/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2020 Androz2091 +Copyright discord.js authors Apache License 2.0 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/discord-voip/README.md b/packages/discord-voip/README.md new file mode 100644 index 0000000000..c016aa8526 --- /dev/null +++ b/packages/discord-voip/README.md @@ -0,0 +1,6 @@ +# Discord VoIP + +This directory contains the VoIP implementation for Discord Player. It is based on [@discordjs/voice](https://npm.im/@discordjs/voice) library with some modifications to make it work with Discord Player. Some of the notable modifications include: + +- Removal of the `VoiceReceiver` and its components. +- Modified `SecretBox` diff --git a/packages/discord-voip/package.json b/packages/discord-voip/package.json new file mode 100644 index 0000000000..25c474067c --- /dev/null +++ b/packages/discord-voip/package.json @@ -0,0 +1,52 @@ +{ + "name": "discord-voip", + "version": "7.0.0-dev.1", + "description": "Discord VoIP library used by discord-player", + "keywords": [ + "discord-player", + "music", + "bot", + "discord.js", + "javascript", + "voip", + "lavalink", + "lavaplayer" + ], + "author": "Androz2091 ", + "homepage": "https://discord-player.js.org", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Androz2091/discord-player.git" + }, + "scripts": { + "build": "tsup && node ./scripts/esm-shim.cjs", + "build:check": "tsc --noEmit", + "lint": "eslint src --ext .ts --fix" + }, + "bugs": { + "url": "https://github.com/Androz2091/discord-player/issues" + }, + "devDependencies": { + "@discord-player/tsconfig": "workspace:^", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + }, + "dependencies": { + "@discord-player/ffmpeg": "workspace:^", + "@discord-player/opus": "workspace:^", + "@discord-player/utils": "workspace:^", + "@types/ws": "^8.5.10", + "tsup": "^8.2.4", + "typescript": "^5.5.4" + } +} \ No newline at end of file diff --git a/packages/discord-voip/scripts/esm-shim.cjs b/packages/discord-voip/scripts/esm-shim.cjs new file mode 100644 index 0000000000..36bf216649 --- /dev/null +++ b/packages/discord-voip/scripts/esm-shim.cjs @@ -0,0 +1,15 @@ +/* eslint-disable */ +const { writeFileSync } = require('fs'); + +const mod = require(`${__dirname}/../dist/index.js`); + +const entries = Object.keys(mod); +const exportsMeta = entries.map((m) => `\t${m}`).join(',\n'); + +const content = [ + `import DiscordPlayer from './index.js';\n`, + `const {\n${exportsMeta}\n} = DiscordPlayer;\n`, + `export {\n${exportsMeta}\n};` +]; + +writeFileSync(`${__dirname}/../dist/index.mjs`, content.join('\n')); diff --git a/packages/discord-voip/src/DataStore.ts b/packages/discord-voip/src/DataStore.ts new file mode 100644 index 0000000000..516929ec1d --- /dev/null +++ b/packages/discord-voip/src/DataStore.ts @@ -0,0 +1,192 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import { GatewayOpcodes } from 'discord-api-types/v10'; +import type { VoiceConnection } from './VoiceConnection'; +import type { AudioPlayer } from './audio/index'; + +export interface JoinConfig { + channelId: string | null; + group: string; + guildId: string; + selfDeaf: boolean; + selfMute: boolean; +} + +/** + * Sends a voice state update to the main websocket shard of a guild, to indicate joining/leaving/moving across + * voice channels. + * + * @param config - The configuration to use when joining the voice channel + */ +export function createJoinVoiceChannelPayload(config: JoinConfig) { + return { + op: GatewayOpcodes.VoiceStateUpdate, + // eslint-disable-next-line id-length + d: { + guild_id: config.guildId, + channel_id: config.channelId, + self_deaf: config.selfDeaf, + self_mute: config.selfMute, + }, + }; +} + +// Voice Connections +const groups = new Map>(); +groups.set('default', new Map()); + +function getOrCreateGroup(group: string) { + const existing = groups.get(group); + if (existing) return existing; + const map = new Map(); + groups.set(group, map); + return map; +} + +/** + * Retrieves the map of group names to maps of voice connections. By default, all voice connections + * are created under the 'default' group. + * + * @returns The group map + */ +export function getGroups() { + return groups; +} + +/** + * Retrieves all the voice connections under the 'default' group. + * + * @param group - The group to look up + * @returns The map of voice connections + */ +export function getVoiceConnections(group?: 'default'): Map; + +/** + * Retrieves all the voice connections under the given group name. + * + * @param group - The group to look up + * @returns The map of voice connections + */ +export function getVoiceConnections(group: string): Map | undefined; + +/** + * Retrieves all the voice connections under the given group name. Defaults to the 'default' group. + * + * @param group - The group to look up + * @returns The map of voice connections + */ +export function getVoiceConnections(group = 'default') { + return groups.get(group); +} + +/** + * Finds a voice connection with the given guild id and group. Defaults to the 'default' group. + * + * @param guildId - The guild id of the voice connection + * @param group - the group that the voice connection was registered with + * @returns The voice connection, if it exists + */ +export function getVoiceConnection(guildId: string, group = 'default') { + return getVoiceConnections(group)?.get(guildId); +} + +export function untrackVoiceConnection(voiceConnection: VoiceConnection) { + return getVoiceConnections(voiceConnection.joinConfig.group)?.delete(voiceConnection.joinConfig.guildId); +} + +export function trackVoiceConnection(voiceConnection: VoiceConnection) { + return getOrCreateGroup(voiceConnection.joinConfig.group).set(voiceConnection.joinConfig.guildId, voiceConnection); +} + +// Audio Players + +// Each audio packet is 20ms long +const FRAME_LENGTH = 20; + +let audioCycleInterval: NodeJS.Timeout | undefined; +let nextTime = -1; + +/** + * A list of created audio players that are still active and haven't been destroyed. + */ +const audioPlayers: AudioPlayer[] = []; + +/** + * Called roughly every 20 milliseconds. Dispatches audio from all players, and then gets the players to prepare + * the next audio frame. + */ +function audioCycleStep() { + if (nextTime === -1) return; + + nextTime += FRAME_LENGTH; + const available = audioPlayers.filter((player) => player.checkPlayable()); + + for (const player of available) { + // eslint-disable-next-line @typescript-eslint/dot-notation + player['_stepDispatch'](); + } + + prepareNextAudioFrame(available); +} + +/** + * Recursively gets the players that have been passed as parameters to prepare audio frames that can be played + * at the start of the next cycle. + */ +function prepareNextAudioFrame(players: AudioPlayer[]) { + const nextPlayer = players.shift(); + + if (!nextPlayer) { + if (nextTime !== -1) { + audioCycleInterval = setTimeout(() => audioCycleStep(), nextTime - Date.now()); + } + + return; + } + + // eslint-disable-next-line @typescript-eslint/dot-notation + nextPlayer['_stepPrepare'](); + + // setImmediate to avoid long audio player chains blocking other scheduled tasks + setImmediate(() => prepareNextAudioFrame(players)); +} + +/** + * Checks whether or not the given audio player is being driven by the data store clock. + * + * @param target - The target to test for + * @returns `true` if it is being tracked, `false` otherwise + */ +export function hasAudioPlayer(target: AudioPlayer) { + return audioPlayers.includes(target); +} + +/** + * Adds an audio player to the data store tracking list, if it isn't already there. + * + * @param player - The player to track + */ +export function addAudioPlayer(player: AudioPlayer) { + if (hasAudioPlayer(player)) return player; + audioPlayers.push(player); + if (audioPlayers.length === 1) { + nextTime = Date.now(); + setImmediate(() => audioCycleStep()); + } + + return player; +} + +/** + * Removes an audio player from the data store tracking list, if it is present there. + */ +export function deleteAudioPlayer(player: AudioPlayer) { + const index = audioPlayers.indexOf(player); + if (index === -1) return; + audioPlayers.splice(index, 1); + if (audioPlayers.length === 0) { + nextTime = -1; + if (audioCycleInterval !== undefined) clearTimeout(audioCycleInterval); + } +} diff --git a/packages/discord-voip/src/VoiceConnection.ts b/packages/discord-voip/src/VoiceConnection.ts new file mode 100644 index 0000000000..6372d98cb9 --- /dev/null +++ b/packages/discord-voip/src/VoiceConnection.ts @@ -0,0 +1,690 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-declaration-merging */ + +import type { Buffer } from 'node:buffer'; +import { EventEmitter } from 'node:events'; +import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v10'; +import type { JoinConfig } from './DataStore'; +import { getVoiceConnection, createJoinVoiceChannelPayload, trackVoiceConnection, untrackVoiceConnection } from './DataStore'; +import type { AudioPlayer } from './audio/AudioPlayer'; +import type { PlayerSubscription } from './audio/PlayerSubscription'; +import { Networking, NetworkingStatusCode, type NetworkingState } from './networking/Networking'; +import type { DiscordGatewayAdapterImplementerMethods } from './util/adapter'; +import { noop } from './util/util'; +import type { CreateVoiceConnectionOptions } from './index'; +import { unsafe } from './common/types'; + +/** + * The various status codes a voice connection can hold at any one time. + */ +export enum VoiceConnectionStatus { + /** + * The `VOICE_SERVER_UPDATE` and `VOICE_STATE_UPDATE` packets have been received, now attempting to establish a voice connection. + */ + Connecting = 'connecting', + + /** + * The voice connection has been destroyed and untracked, it cannot be reused. + */ + Destroyed = 'destroyed', + + /** + * The voice connection has either been severed or not established. + */ + Disconnected = 'disconnected', + + /** + * A voice connection has been established, and is ready to be used. + */ + Ready = 'ready', + + /** + * Sending a packet to the main Discord gateway to indicate we want to change our voice state. + */ + Signalling = 'signalling' +} + +/** + * The state that a VoiceConnection will be in when it is waiting to receive a VOICE_SERVER_UPDATE and + * VOICE_STATE_UPDATE packet from Discord, provided by the adapter. + */ +export interface VoiceConnectionSignallingState { + adapter: DiscordGatewayAdapterImplementerMethods; + status: VoiceConnectionStatus.Signalling; + subscription?: PlayerSubscription | undefined; +} + +/** + * The reasons a voice connection can be in the disconnected state. + */ +export enum VoiceConnectionDisconnectReason { + /** + * When the WebSocket connection has been closed. + */ + WebSocketClose, + + /** + * When the adapter was unable to send a message requested by the VoiceConnection. + */ + AdapterUnavailable, + + /** + * When a VOICE_SERVER_UPDATE packet is received with a null endpoint, causing the connection to be severed. + */ + EndpointRemoved, + + /** + * When a manual disconnect was requested. + */ + Manual +} + +/** + * The state that a VoiceConnection will be in when it is not connected to a Discord voice server nor is + * it attempting to connect. You can manually attempt to reconnect using VoiceConnection#reconnect. + */ +export interface VoiceConnectionDisconnectedBaseState { + adapter: DiscordGatewayAdapterImplementerMethods; + status: VoiceConnectionStatus.Disconnected; + subscription?: PlayerSubscription | undefined; +} + +/** + * The state that a VoiceConnection will be in when it is not connected to a Discord voice server nor is + * it attempting to connect. You can manually attempt to reconnect using VoiceConnection#reconnect. + */ +export interface VoiceConnectionDisconnectedOtherState extends VoiceConnectionDisconnectedBaseState { + reason: Exclude; +} + +/** + * The state that a VoiceConnection will be in when its WebSocket connection was closed. + * You can manually attempt to reconnect using VoiceConnection#reconnect. + */ +export interface VoiceConnectionDisconnectedWebSocketState extends VoiceConnectionDisconnectedBaseState { + /** + * The close code of the WebSocket connection to the Discord voice server. + */ + closeCode: number; + + reason: VoiceConnectionDisconnectReason.WebSocketClose; +} + +/** + * The states that a VoiceConnection can be in when it is not connected to a Discord voice server nor is + * it attempting to connect. You can manually attempt to connect using VoiceConnection#reconnect. + */ +export type VoiceConnectionDisconnectedState = VoiceConnectionDisconnectedOtherState | VoiceConnectionDisconnectedWebSocketState; + +/** + * The state that a VoiceConnection will be in when it is establishing a connection to a Discord + * voice server. + */ +export interface VoiceConnectionConnectingState { + adapter: DiscordGatewayAdapterImplementerMethods; + networking: Networking; + status: VoiceConnectionStatus.Connecting; + subscription?: PlayerSubscription | undefined; +} + +/** + * The state that a VoiceConnection will be in when it has an active connection to a Discord + * voice server. + */ +export interface VoiceConnectionReadyState { + adapter: DiscordGatewayAdapterImplementerMethods; + networking: Networking; + status: VoiceConnectionStatus.Ready; + subscription?: PlayerSubscription | undefined; +} + +/** + * The state that a VoiceConnection will be in when it has been permanently been destroyed by the + * user and untracked by the library. It cannot be reconnected, instead, a new VoiceConnection + * needs to be established. + */ +export interface VoiceConnectionDestroyedState { + status: VoiceConnectionStatus.Destroyed; +} + +/** + * The various states that a voice connection can be in. + */ +export type VoiceConnectionState = VoiceConnectionConnectingState | VoiceConnectionDestroyedState | VoiceConnectionDisconnectedState | VoiceConnectionReadyState | VoiceConnectionSignallingState; + +export interface VoiceConnection extends EventEmitter { + /** + * Emitted when there is an error emitted from the voice connection + * + * @eventProperty + */ + on(event: 'error', listener: (error: Error) => void): this; + /** + * Emitted debugging information about the voice connection + * + * @eventProperty + */ + on(event: 'debug', listener: (message: string) => void): this; + /** + * Emitted when the state of the voice connection changes + * + * @eventProperty + */ + on(event: 'stateChange', listener: (oldState: VoiceConnectionState, newState: VoiceConnectionState) => void): this; + /** + * Emitted when the state of the voice connection changes to a specific status + * + * @eventProperty + */ + on(event: Event, listener: (oldState: VoiceConnectionState, newState: VoiceConnectionState & { status: Event }) => void): this; +} + +/** + * A connection to the voice server of a Guild, can be used to play audio in voice channels. + */ +export class VoiceConnection extends EventEmitter { + /** + * The number of consecutive rejoin attempts. Initially 0, and increments for each rejoin. + * When a connection is successfully established, it resets to 0. + */ + public rejoinAttempts: number; + + /** + * The state of the voice connection. + */ + private _state: VoiceConnectionState; + + /** + * A configuration storing all the data needed to reconnect to a Guild's voice server. + * + * @internal + */ + public readonly joinConfig: JoinConfig; + + /** + * The two packets needed to successfully establish a voice connection. They are received + * from the main Discord gateway after signalling to change the voice state. + */ + private readonly packets: { + server: GatewayVoiceServerUpdateDispatchData | undefined; + state: GatewayVoiceStateUpdateDispatchData | undefined; + }; + + /** + * The debug logger function, if debugging is enabled. + */ + private readonly debug: ((message: string) => void) | null; + + /** + * Creates a new voice connection. + * + * @param joinConfig - The data required to establish the voice connection + * @param options - The options used to create this voice connection + */ + public constructor(joinConfig: JoinConfig, options: CreateVoiceConnectionOptions) { + super(); + + this.debug = options.debug ? (message: string) => this.emit('debug', message) : null; + this.rejoinAttempts = 0; + + this.onNetworkingClose = this.onNetworkingClose.bind(this); + this.onNetworkingStateChange = this.onNetworkingStateChange.bind(this); + this.onNetworkingError = this.onNetworkingError.bind(this); + this.onNetworkingDebug = this.onNetworkingDebug.bind(this); + + const adapter = options.adapterCreator({ + onVoiceServerUpdate: (data) => this.addServerPacket(data), + onVoiceStateUpdate: (data) => this.addStatePacket(data), + destroy: () => this.destroy(false) + }); + + this._state = { status: VoiceConnectionStatus.Signalling, adapter }; + + this.packets = { + server: undefined, + state: undefined + }; + + this.joinConfig = joinConfig; + } + + /** + * The current state of the voice connection. + */ + public get state() { + return this._state; + } + + /** + * Updates the state of the voice connection, performing clean-up operations where necessary. + */ + public set state(newState: VoiceConnectionState) { + const oldState = this._state; + const oldNetworking = Reflect.get(oldState, 'networking') as Networking | undefined; + const newNetworking = Reflect.get(newState, 'networking') as Networking | undefined; + + const oldSubscription = Reflect.get(oldState, 'subscription') as PlayerSubscription | undefined; + const newSubscription = Reflect.get(newState, 'subscription') as PlayerSubscription | undefined; + + if (oldNetworking !== newNetworking) { + if (oldNetworking) { + oldNetworking.on('error', noop); + oldNetworking.off('debug', this.onNetworkingDebug); + oldNetworking.off('error', this.onNetworkingError); + oldNetworking.off('close', this.onNetworkingClose); + oldNetworking.off('stateChange', this.onNetworkingStateChange); + oldNetworking.destroy(); + } + } + + if (newState.status === VoiceConnectionStatus.Ready) { + this.rejoinAttempts = 0; + } + + // If destroyed, the adapter can also be destroyed so it can be cleaned up by the user + if (oldState.status !== VoiceConnectionStatus.Destroyed && newState.status === VoiceConnectionStatus.Destroyed) { + oldState.adapter.destroy(); + } + + this._state = newState; + + if (oldSubscription && oldSubscription !== newSubscription) { + oldSubscription.unsubscribe(); + } + + this.emit('stateChange', oldState, newState); + if (oldState.status !== newState.status) { + this.emit(newState.status, oldState, newState as unsafe); + } + } + + /** + * Registers a `VOICE_SERVER_UPDATE` packet to the voice connection. This will cause it to reconnect using the + * new data provided in the packet. + * + * @param packet - The received `VOICE_SERVER_UPDATE` packet + */ + private addServerPacket(packet: GatewayVoiceServerUpdateDispatchData) { + this.packets.server = packet; + if (packet.endpoint) { + this.configureNetworking(); + } else if (this.state.status !== VoiceConnectionStatus.Destroyed) { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.EndpointRemoved + }; + } + } + + /** + * Registers a `VOICE_STATE_UPDATE` packet to the voice connection. Most importantly, it stores the id of the + * channel that the client is connected to. + * + * @param packet - The received `VOICE_STATE_UPDATE` packet + */ + private addStatePacket(packet: GatewayVoiceStateUpdateDispatchData) { + this.packets.state = packet; + + if (packet.self_deaf !== undefined) this.joinConfig.selfDeaf = packet.self_deaf; + if (packet.self_mute !== undefined) this.joinConfig.selfMute = packet.self_mute; + if (packet.channel_id) this.joinConfig.channelId = packet.channel_id; + /* + the channel_id being null doesn't necessarily mean it was intended for the client to leave the voice channel + as it may have disconnected due to network failure. This will be gracefully handled once the voice websocket + dies, and then it is up to the user to decide how they wish to handle this. + */ + } + + /** + * Attempts to configure a networking instance for this voice connection using the received packets. + * Both packets are required, and any existing networking instance will be destroyed. + * + * @remarks + * This is called when the voice server of the connection changes, e.g. if the bot is moved into a + * different channel in the same guild but has a different voice server. In this instance, the connection + * needs to be re-established to the new voice server. + * + * The connection will transition to the Connecting state when this is called. + */ + public configureNetworking() { + const { server, state } = this.packets; + if (!server || !state || this.state.status === VoiceConnectionStatus.Destroyed || !server.endpoint) return; + + const networking = new Networking( + { + endpoint: server.endpoint, + serverId: server.guild_id, + token: server.token, + sessionId: state.session_id, + userId: state.user_id + }, + Boolean(this.debug) + ); + + networking.once('close', this.onNetworkingClose); + networking.on('stateChange', this.onNetworkingStateChange); + networking.on('error', this.onNetworkingError); + networking.on('debug', this.onNetworkingDebug); + + this.state = { + ...this.state, + status: VoiceConnectionStatus.Connecting, + networking + }; + } + + /** + * Called when the networking instance for this connection closes. If the close code is 4014 (do not reconnect), + * the voice connection will transition to the Disconnected state which will store the close code. You can + * decide whether or not to reconnect when this occurs by listening for the state change and calling reconnect(). + * + * @remarks + * If the close code was anything other than 4014, it is likely that the closing was not intended, and so the + * VoiceConnection will signal to Discord that it would like to rejoin the channel. This automatically attempts + * to re-establish the connection. This would be seen as a transition from the Ready state to the Signalling state. + * @param code - The close code + */ + private onNetworkingClose(code: number) { + if (this.state.status === VoiceConnectionStatus.Destroyed) return; + // If networking closes, try to connect to the voice channel again. + if (code === 4_014) { + // Disconnected - networking is already destroyed here + this.state = { + ...this.state, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.WebSocketClose, + closeCode: code + }; + } else { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Signalling + }; + this.rejoinAttempts++; + if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.AdapterUnavailable + }; + } + } + } + + /** + * Called when the state of the networking instance changes. This is used to derive the state of the voice connection. + * + * @param oldState - The previous state + * @param newState - The new state + */ + private onNetworkingStateChange(oldState: NetworkingState, newState: NetworkingState) { + if (oldState.code === newState.code) return; + if (this.state.status !== VoiceConnectionStatus.Connecting && this.state.status !== VoiceConnectionStatus.Ready) return; + + if (newState.code === NetworkingStatusCode.Ready) { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Ready + }; + } else if (newState.code !== NetworkingStatusCode.Closed) { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Connecting + }; + } + } + + /** + * Propagates errors from the underlying network instance. + * + * @param error - The error to propagate + */ + private onNetworkingError(error: Error) { + this.emit('error', error); + } + + /** + * Propagates debug messages from the underlying network instance. + * + * @param message - The debug message to propagate + */ + private onNetworkingDebug(message: string) { + this.debug?.(`[NW] ${message}`); + } + + /** + * Prepares an audio packet for dispatch. + * + * @param buffer - The Opus packet to prepare + */ + public prepareAudioPacket(buffer: Buffer) { + const state = this.state; + if (state.status !== VoiceConnectionStatus.Ready) return; + return state.networking.prepareAudioPacket(buffer); + } + + /** + * Dispatches the previously prepared audio packet (if any) + */ + public dispatchAudio() { + const state = this.state; + if (state.status !== VoiceConnectionStatus.Ready) return; + return state.networking.dispatchAudio(); + } + + /** + * Prepares an audio packet and dispatches it immediately. + * + * @param buffer - The Opus packet to play + */ + public playOpusPacket(buffer: Buffer) { + const state = this.state; + if (state.status !== VoiceConnectionStatus.Ready) return; + state.networking.prepareAudioPacket(buffer); + return state.networking.dispatchAudio(); + } + + /** + * Destroys the VoiceConnection, preventing it from connecting to voice again. + * This method should be called when you no longer require the VoiceConnection to + * prevent memory leaks. + * + * @param adapterAvailable - Whether the adapter can be used + */ + public destroy(adapterAvailable = true) { + if (this.state.status === VoiceConnectionStatus.Destroyed) { + throw new Error('Cannot destroy VoiceConnection - it has already been destroyed'); + } + + if (getVoiceConnection(this.joinConfig.guildId, this.joinConfig.group) === this) { + untrackVoiceConnection(this); + } + + if (adapterAvailable) { + this.state.adapter.sendPayload(createJoinVoiceChannelPayload({ ...this.joinConfig, channelId: null })); + } + + this.state = { + status: VoiceConnectionStatus.Destroyed + }; + } + + /** + * Disconnects the VoiceConnection, allowing the possibility of rejoining later on. + * + * @returns `true` if the connection was successfully disconnected + */ + public disconnect() { + if (this.state.status === VoiceConnectionStatus.Destroyed || this.state.status === VoiceConnectionStatus.Signalling) { + return false; + } + + this.joinConfig.channelId = null; + if (!this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) { + this.state = { + adapter: this.state.adapter, + subscription: this.state.subscription, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.AdapterUnavailable + }; + return false; + } + + this.state = { + adapter: this.state.adapter, + reason: VoiceConnectionDisconnectReason.Manual, + status: VoiceConnectionStatus.Disconnected + }; + return true; + } + + /** + * Attempts to rejoin (better explanation soon:tm:) + * + * @remarks + * Calling this method successfully will automatically increment the `rejoinAttempts` counter, + * which you can use to inform whether or not you'd like to keep attempting to reconnect your + * voice connection. + * + * A state transition from Disconnected to Signalling will be observed when this is called. + */ + public rejoin(joinConfig?: Omit) { + if (this.state.status === VoiceConnectionStatus.Destroyed) { + return false; + } + + const notReady = this.state.status !== VoiceConnectionStatus.Ready; + + if (notReady) this.rejoinAttempts++; + Object.assign(this.joinConfig, joinConfig); + if (this.state.adapter.sendPayload(createJoinVoiceChannelPayload(this.joinConfig))) { + if (notReady) { + this.state = { + ...this.state, + status: VoiceConnectionStatus.Signalling + }; + } + + return true; + } + + this.state = { + adapter: this.state.adapter, + subscription: this.state.subscription, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.AdapterUnavailable + }; + return false; + } + + /** + * Updates the speaking status of the voice connection. This is used when audio players are done playing audio, + * and need to signal that the connection is no longer playing audio. + * + * @param enabled - Whether or not to show as speaking + */ + public setSpeaking(enabled: boolean) { + if (this.state.status !== VoiceConnectionStatus.Ready) return false; + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + return this.state.networking.setSpeaking(enabled); + } + + /** + * Subscribes to an audio player, allowing the player to play audio on this voice connection. + * + * @param player - The audio player to subscribe to + * @returns The created subscription + */ + public subscribe(player: AudioPlayer) { + if (this.state.status === VoiceConnectionStatus.Destroyed) return; + + // eslint-disable-next-line @typescript-eslint/dot-notation + const subscription = player['subscribe'](this); + + this.state = { + ...this.state, + subscription + }; + + return subscription; + } + + /** + * The latest ping (in milliseconds) for the WebSocket connection and audio playback for this voice + * connection, if this data is available. + * + * @remarks + * For this data to be available, the VoiceConnection must be in the Ready state, and its underlying + * WebSocket connection and UDP socket must have had at least one ping-pong exchange. + */ + public get ping() { + if (this.state.status === VoiceConnectionStatus.Ready && this.state.networking.state.code === NetworkingStatusCode.Ready) { + return { + ws: this.state.networking.state.ws.ping, + udp: this.state.networking.state.udp.ping + }; + } + + return { + ws: undefined, + udp: undefined + }; + } + + /** + * Called when a subscription of this voice connection to an audio player is removed. + * + * @param subscription - The removed subscription + */ + protected onSubscriptionRemoved(subscription: PlayerSubscription) { + if (this.state.status !== VoiceConnectionStatus.Destroyed && this.state.subscription === subscription) { + this.state = { + ...this.state, + subscription: undefined + }; + } + } +} + +/** + * Creates a new voice connection. + * + * @param joinConfig - The data required to establish the voice connection + * @param options - The options to use when joining the voice channel + */ +export function createVoiceConnection(joinConfig: JoinConfig, options: CreateVoiceConnectionOptions) { + const payload = createJoinVoiceChannelPayload(joinConfig); + const existing = getVoiceConnection(joinConfig.guildId, joinConfig.group); + if (existing && existing.state.status !== VoiceConnectionStatus.Destroyed) { + if (existing.state.status === VoiceConnectionStatus.Disconnected) { + existing.rejoin({ + channelId: joinConfig.channelId, + selfDeaf: joinConfig.selfDeaf, + selfMute: joinConfig.selfMute + }); + } else if (!existing.state.adapter.sendPayload(payload)) { + existing.state = { + ...existing.state, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.AdapterUnavailable + }; + } + + return existing; + } + + const voiceConnection = new VoiceConnection(joinConfig, options); + trackVoiceConnection(voiceConnection); + if (voiceConnection.state.status !== VoiceConnectionStatus.Destroyed && !voiceConnection.state.adapter.sendPayload(payload)) { + voiceConnection.state = { + ...voiceConnection.state, + status: VoiceConnectionStatus.Disconnected, + reason: VoiceConnectionDisconnectReason.AdapterUnavailable + }; + } + + return voiceConnection; +} diff --git a/packages/discord-voip/src/audio/AudioPlayer.ts b/packages/discord-voip/src/audio/AudioPlayer.ts new file mode 100644 index 0000000000..9bab67a3b8 --- /dev/null +++ b/packages/discord-voip/src/audio/AudioPlayer.ts @@ -0,0 +1,656 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/* eslint-disable @typescript-eslint/prefer-ts-expect-error, @typescript-eslint/method-signature-style */ +import { Buffer } from 'node:buffer'; +import { EventEmitter } from 'node:events'; +import { addAudioPlayer, deleteAudioPlayer } from '../DataStore'; +import { VoiceConnectionStatus, type VoiceConnection } from '../VoiceConnection'; +import { noop } from '../util/util'; +import { AudioPlayerError } from './AudioPlayerError'; +import type { AudioResource } from './AudioResource'; +import { PlayerSubscription } from './PlayerSubscription'; +import { unsafe } from '../common/types'; + +// The Opus "silent" frame +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 + * voice connections to play to. + */ +export enum NoSubscriberBehavior { + /** + * Pauses playing the stream until a voice connection becomes available. + */ + Pause = 'pause', + + /** + * Continues to play through the resource regardless. + */ + Play = 'play', + + /** + * The player stops and enters the Idle state. + */ + Stop = 'stop', +} + +export enum AudioPlayerStatus { + /** + * When the player has paused itself. Only possible with the "pause" no subscriber behavior. + */ + AutoPaused = 'autopaused', + + /** + * When the player is waiting for an audio resource to become readable before transitioning to Playing. + */ + Buffering = 'buffering', + + /** + * When there is currently no resource for the player to be playing. + */ + Idle = 'idle', + + /** + * When the player has been manually paused. + */ + Paused = 'paused', + + /** + * When the player is actively playing an audio resource. + */ + Playing = 'playing', +} + +/** + * Options that can be passed when creating an audio player, used to specify its behavior. + */ +export interface CreateAudioPlayerOptions { + behaviors?: { + maxMissedFrames?: number; + noSubscriber?: NoSubscriberBehavior; + }; + debug?: boolean; +} + +/** + * The state that an AudioPlayer is in when it has no resource to play. This is the starting state. + */ +export interface AudioPlayerIdleState { + status: AudioPlayerStatus.Idle; +} + +/** + * The state that an AudioPlayer is in when it is waiting for a resource to become readable. Once this + * happens, the AudioPlayer will enter the Playing state. If the resource ends/errors before this, then + * it will re-enter the Idle state. + */ +export interface AudioPlayerBufferingState { + onFailureCallback: () => void; + onReadableCallback: () => void; + onStreamError: (error: Error) => void; + /** + * The resource that the AudioPlayer is waiting for + */ + resource: AudioResource; + status: AudioPlayerStatus.Buffering; +} + +/** + * The state that an AudioPlayer is in when it is actively playing an AudioResource. When playback ends, + * it will enter the Idle state. + */ +export interface AudioPlayerPlayingState { + /** + * The number of consecutive times that the audio resource has been unable to provide an Opus frame. + */ + missedFrames: number; + onStreamError: (error: Error) => void; + + /** + * The playback duration in milliseconds of the current audio resource. This includes filler silence packets + * that have been played when the resource was buffering. + */ + playbackDuration: number; + + /** + * The resource that is being played. + */ + resource: AudioResource; + + status: AudioPlayerStatus.Playing; +} + +/** + * The state that an AudioPlayer is in when it has either been explicitly paused by the user, or done + * automatically by the AudioPlayer itself if there are no available subscribers. + */ +export interface AudioPlayerPausedState { + onStreamError: (error: Error) => void; + /** + * The playback duration in milliseconds of the current audio resource. This includes filler silence packets + * that have been played when the resource was buffering. + */ + playbackDuration: number; + + /** + * The current resource of the audio player. + */ + resource: AudioResource; + + /** + * How many silence packets still need to be played to avoid audio interpolation due to the stream suddenly pausing. + */ + silencePacketsRemaining: number; + + status: AudioPlayerStatus.AutoPaused | AudioPlayerStatus.Paused; +} + +/** + * The various states that the player can be in. + */ +export type AudioPlayerState = + | AudioPlayerBufferingState + | AudioPlayerIdleState + | AudioPlayerPausedState + | AudioPlayerPlayingState; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface AudioPlayer extends EventEmitter { + /** + * Emitted when there is an error emitted from the audio resource played by the audio player + * + * @eventProperty + */ + on(event: 'error', listener: (error: AudioPlayerError) => void): this; + /** + * Emitted debugging information about the audio player + * + * @eventProperty + */ + on(event: 'debug', listener: (message: string) => void): this; + /** + * Emitted when the state of the audio player changes + * + * @eventProperty + */ + on(event: 'stateChange', listener: (oldState: AudioPlayerState, newState: AudioPlayerState) => void): this; + /** + * Emitted when the audio player is subscribed to a voice connection + * + * @eventProperty + */ + on(event: 'subscribe' | 'unsubscribe', listener: (subscription: PlayerSubscription) => void): this; + /** + * Emitted when the status of state changes to a specific status + * + * @eventProperty + */ + on( + event: Event, + listener: (oldState: AudioPlayerState, newState: AudioPlayerState & { status: Event }) => void, + ): this; +} + +/** + * Stringifies an AudioPlayerState instance. + * + * @param state - The state to stringify + */ +function stringifyState(state: AudioPlayerState) { + return JSON.stringify({ + ...state, + resource: Reflect.has(state, 'resource'), + stepTimeout: Reflect.has(state, 'stepTimeout'), + }); +} + +/** + * Used to play audio resources (i.e. tracks, streams) to voice connections. + * + * @remarks + * Audio players are designed to be re-used - even if a resource has finished playing, the player itself + * can still be used. + * + * The AudioPlayer drives the timing of playback, and therefore is unaffected by voice connections + * becoming unavailable. Its behavior in these scenarios can be configured. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class AudioPlayer extends EventEmitter { + /** + * The state that the AudioPlayer is in. + */ + private _state: AudioPlayerState; + + /** + * A list of VoiceConnections that are registered to this AudioPlayer. The player will attempt to play audio + * to the streams in this list. + */ + private readonly subscribers: PlayerSubscription[] = []; + + /** + * The behavior that the player should follow when it enters certain situations. + */ + private readonly behaviors: { + maxMissedFrames: number; + noSubscriber: NoSubscriberBehavior; + }; + + /** + * The debug logger function, if debugging is enabled. + */ + private readonly debug: ((message: string) => void) | null; + + /** + * Creates a new AudioPlayer. + */ + public constructor(options: CreateAudioPlayerOptions = {}) { + super(); + this._state = { status: AudioPlayerStatus.Idle }; + this.behaviors = { + noSubscriber: NoSubscriberBehavior.Pause, + maxMissedFrames: 5, + ...options.behaviors, + }; + this.debug = options.debug === false ? null : (message: string) => this.emit('debug', message); + } + + /** + * A list of subscribed voice connections that can currently receive audio to play. + */ + public get playable() { + return this.subscribers + .filter(({ connection }) => connection.state.status === VoiceConnectionStatus.Ready) + .map(({ connection }) => connection); + } + + /** + * Subscribes a VoiceConnection to the audio player's play list. If the VoiceConnection is already subscribed, + * then the existing subscription is used. + * + * @remarks + * This method should not be directly called. Instead, use VoiceConnection#subscribe. + * @param connection - The connection to subscribe + * @returns The new subscription if the voice connection is not yet subscribed, otherwise the existing subscription + */ + // @ts-ignore + private subscribe(connection: VoiceConnection) { + const existingSubscription = this.subscribers.find((subscription) => subscription.connection === connection); + if (!existingSubscription) { + const subscription = new PlayerSubscription(connection, this); + this.subscribers.push(subscription); + setImmediate(() => this.emit('subscribe', subscription)); + return subscription; + } + + return existingSubscription; + } + + /** + * Unsubscribes a subscription - i.e. removes a voice connection from the play list of the audio player. + * + * @remarks + * This method should not be directly called. Instead, use PlayerSubscription#unsubscribe. + * @param subscription - The subscription to remove + * @returns Whether or not the subscription existed on the player and was removed + */ + // @ts-ignore + private unsubscribe(subscription: PlayerSubscription) { + const index = this.subscribers.indexOf(subscription); + const exists = index !== -1; + if (exists) { + this.subscribers.splice(index, 1); + subscription.connection.setSpeaking(false); + this.emit('unsubscribe', subscription); + } + + return exists; + } + + /** + * The state that the player is in. + */ + public get state() { + return this._state; + } + + /** + * Sets a new state for the player, performing clean-up operations where necessary. + */ + public set state(newState: AudioPlayerState) { + const oldState = this._state; + const newResource = Reflect.get(newState, 'resource') as AudioResource | undefined; + + if (oldState.status !== AudioPlayerStatus.Idle && oldState.resource !== newResource) { + oldState.resource.playStream.on('error', noop); + oldState.resource.playStream.off('error', oldState.onStreamError); + oldState.resource.audioPlayer = undefined; + oldState.resource.playStream.destroy(); + oldState.resource.playStream.read(); // required to ensure buffered data is drained, prevents memory leak + } + + // When leaving the Buffering state (or buffering a new resource), then remove the event listeners from it + if ( + oldState.status === AudioPlayerStatus.Buffering && + (newState.status !== AudioPlayerStatus.Buffering || newState.resource !== oldState.resource) + ) { + oldState.resource.playStream.off('end', oldState.onFailureCallback); + oldState.resource.playStream.off('close', oldState.onFailureCallback); + oldState.resource.playStream.off('finish', oldState.onFailureCallback); + oldState.resource.playStream.off('readable', oldState.onReadableCallback); + } + + // transitioning into an idle should ensure that connections stop speaking + if (newState.status === AudioPlayerStatus.Idle) { + this._signalStopSpeaking(); + deleteAudioPlayer(this); + } + + // attach to the global audio player timer + if (newResource) { + addAudioPlayer(this); + } + + // playing -> playing state changes should still transition if a resource changed (seems like it would be useful!) + const didChangeResources = + oldState.status !== AudioPlayerStatus.Idle && + newState.status === AudioPlayerStatus.Playing && + oldState.resource !== newState.resource; + + this._state = newState; + + this.emit('stateChange', oldState, this._state); + if (oldState.status !== newState.status || didChangeResources) { + this.emit(newState.status, oldState, this._state as unsafe); + } + + this.debug?.(`state change:\nfrom ${stringifyState(oldState)}\nto ${stringifyState(newState)}`); + } + + /** + * Plays a new resource on the player. If the player is already playing a resource, the existing resource is destroyed + * (it cannot be reused, even in another player) and is replaced with the new resource. + * + * @remarks + * The player will transition to the Playing state once playback begins, and will return to the Idle state once + * playback is ended. + * + * If the player was previously playing a resource and this method is called, the player will not transition to the + * Idle state during the swap over. + * @param resource - The resource to play + * @throws Will throw if attempting to play an audio resource that has already ended, or is being played by another player + */ + public play(resource: AudioResource) { + if (resource.ended) { + throw new Error('Cannot play a resource that has already ended.'); + } + + if (resource.audioPlayer) { + if (resource.audioPlayer === this) { + return; + } + + throw new Error('Resource is already being played by another audio player.'); + } + + resource.audioPlayer = this; + + // Attach error listeners to the stream that will propagate the error and then return to the Idle + // state if the resource is still being used. + const onStreamError = (error: Error) => { + if (this.state.status !== AudioPlayerStatus.Idle) { + this.emit('error', new AudioPlayerError(error, this.state.resource)); + } + + if (this.state.status !== AudioPlayerStatus.Idle && this.state.resource === resource) { + this.state = { + status: AudioPlayerStatus.Idle, + }; + } + }; + + resource.playStream.once('error', onStreamError); + + if (resource.started) { + this.state = { + status: AudioPlayerStatus.Playing, + missedFrames: 0, + playbackDuration: 0, + resource, + onStreamError, + }; + } else { + const onReadableCallback = () => { + if (this.state.status === AudioPlayerStatus.Buffering && this.state.resource === resource) { + this.state = { + status: AudioPlayerStatus.Playing, + missedFrames: 0, + playbackDuration: 0, + resource, + onStreamError, + }; + } + }; + + const onFailureCallback = () => { + if (this.state.status === AudioPlayerStatus.Buffering && this.state.resource === resource) { + this.state = { + status: AudioPlayerStatus.Idle, + }; + } + }; + + resource.playStream.once('readable', onReadableCallback); + + resource.playStream.once('end', onFailureCallback); + resource.playStream.once('close', onFailureCallback); + resource.playStream.once('finish', onFailureCallback); + + this.state = { + status: AudioPlayerStatus.Buffering, + resource, + onReadableCallback, + onFailureCallback, + onStreamError, + }; + } + } + + /** + * Pauses playback of the current resource, if any. + * + * @param interpolateSilence - If true, the player will play 5 packets of silence after pausing to prevent audio glitches + * @returns `true` if the player was successfully paused, otherwise `false` + */ + public pause(interpolateSilence = true) { + if (this.state.status !== AudioPlayerStatus.Playing) return false; + this.state = { + ...this.state, + status: AudioPlayerStatus.Paused, + silencePacketsRemaining: interpolateSilence ? 5 : 0, + }; + return true; + } + + /** + * Unpauses playback of the current resource, if any. + * + * @returns `true` if the player was successfully unpaused, otherwise `false` + */ + public unpause() { + if (this.state.status !== AudioPlayerStatus.Paused) return false; + this.state = { + ...this.state, + status: AudioPlayerStatus.Playing, + missedFrames: 0, + }; + return true; + } + + /** + * Stops playback of the current resource and destroys the resource. The player will either transition to the Idle state, + * or remain in its current state until the silence padding frames of the resource have been played. + * + * @param force - If true, will force the player to enter the Idle state even if the resource has silence padding frames + * @returns `true` if the player will come to a stop, otherwise `false` + */ + public stop(force = false) { + if (this.state.status === AudioPlayerStatus.Idle) return false; + if (force || this.state.resource.silencePaddingFrames === 0) { + this.state = { + status: AudioPlayerStatus.Idle, + }; + } else if (this.state.resource.silenceRemaining === -1) { + this.state.resource.silenceRemaining = this.state.resource.silencePaddingFrames; + } + + return true; + } + + /** + * Checks whether the underlying resource (if any) is playable (readable) + * + * @returns `true` if the resource is playable, otherwise `false` + */ + public checkPlayable() { + const state = this._state; + if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return false; + + // If the stream has been destroyed or is no longer readable, then transition to the Idle state. + if (!state.resource.readable) { + this.state = { + status: AudioPlayerStatus.Idle, + }; + return false; + } + + return true; + } + + /** + * Called roughly every 20ms by the global audio player timer. Dispatches any audio packets that are buffered + * by the active connections of this audio player. + */ + // @ts-ignore + private _stepDispatch() { + const state = this._state; + + // Guard against the Idle state + if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return; + + // Dispatch any audio packets that were prepared in the previous cycle + for (const connection of this.playable) { + connection.dispatchAudio(); + } + } + + /** + * Called roughly every 20ms by the global audio player timer. Attempts to read an audio packet from the + * underlying resource of the stream, and then has all the active connections of the audio player prepare it + * (encrypt it, append header data) so that it is ready to play at the start of the next cycle. + */ + // @ts-ignore + private _stepPrepare() { + const state = this._state; + + // Guard against the Idle state + if (state.status === AudioPlayerStatus.Idle || state.status === AudioPlayerStatus.Buffering) return; + + // List of connections that can receive the packet + const playable = this.playable; + + /* If the player was previously in the AutoPaused state, check to see whether there are newly available + connections, allowing us to transition out of the AutoPaused state back into the Playing state */ + if (state.status === AudioPlayerStatus.AutoPaused && playable.length > 0) { + this.state = { + ...state, + status: AudioPlayerStatus.Playing, + missedFrames: 0, + }; + } + + /* If the player is (auto)paused, check to see whether silence packets should be played and + set a timeout to begin the next cycle, ending the current cycle here. */ + if (state.status === AudioPlayerStatus.Paused || state.status === AudioPlayerStatus.AutoPaused) { + if (state.silencePacketsRemaining > 0) { + state.silencePacketsRemaining--; + this._preparePacket(SILENCE_FRAME, playable, state); + if (state.silencePacketsRemaining === 0) { + this._signalStopSpeaking(); + } + } + + return; + } + + // If there are no available connections in this cycle, observe the configured "no subscriber" behavior. + if (playable.length === 0) { + if (this.behaviors.noSubscriber === NoSubscriberBehavior.Pause) { + this.state = { + ...state, + status: AudioPlayerStatus.AutoPaused, + silencePacketsRemaining: 5, + }; + return; + } else if (this.behaviors.noSubscriber === NoSubscriberBehavior.Stop) { + this.stop(true); + } + } + + /** + * Attempt to read an Opus packet from the resource. If there isn't an available packet, + * play a silence packet. If there are 5 consecutive cycles with failed reads, then the + * playback will end. + */ + const packet: Buffer | null = state.resource.read(); + + if (state.status === AudioPlayerStatus.Playing) { + if (packet) { + this._preparePacket(packet, playable, state); + state.missedFrames = 0; + } else { + this._preparePacket(SILENCE_FRAME, playable, state); + state.missedFrames++; + if (state.missedFrames >= this.behaviors.maxMissedFrames) { + this.stop(); + } + } + } + } + + /** + * Signals to all the subscribed connections that they should send a packet to Discord indicating + * they are no longer speaking. Called once playback of a resource ends. + */ + private _signalStopSpeaking() { + for (const { connection } of this.subscribers) { + connection.setSpeaking(false); + } + } + + /** + * Instructs the given connections to each prepare this packet to be played at the start of the + * next cycle. + * + * @param packet - The Opus packet to be prepared by each receiver + * @param receivers - The connections that should play this packet + */ + private _preparePacket( + packet: Buffer, + receivers: VoiceConnection[], + state: AudioPlayerPausedState | AudioPlayerPlayingState, + ) { + state.playbackDuration += 20; + for (const connection of receivers) { + connection.prepareAudioPacket(packet); + } + } +} + +/** + * Creates a new AudioPlayer to be used. + */ +export function createAudioPlayer(options?: CreateAudioPlayerOptions) { + return new AudioPlayer(options); +} diff --git a/packages/discord-voip/src/audio/AudioPlayerError.ts b/packages/discord-voip/src/audio/AudioPlayerError.ts new file mode 100644 index 0000000000..ab9ca8abf1 --- /dev/null +++ b/packages/discord-voip/src/audio/AudioPlayerError.ts @@ -0,0 +1,22 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import type { AudioResource } from './AudioResource'; + +/** + * An error emitted by an AudioPlayer. Contains an attached resource to aid with + * debugging and identifying where the error came from. + */ +export class AudioPlayerError extends Error { + /** + * The resource associated with the audio player at the time the error was thrown. + */ + public readonly resource: AudioResource; + + public constructor(error: Error, resource: AudioResource) { + super(error.message); + this.resource = resource; + this.name = error.name; + this.stack = error.stack!; + } +} diff --git a/packages/discord-voip/src/audio/AudioResource.ts b/packages/discord-voip/src/audio/AudioResource.ts new file mode 100644 index 0000000000..6207913be6 --- /dev/null +++ b/packages/discord-voip/src/audio/AudioResource.ts @@ -0,0 +1,273 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import type { Buffer } from 'node:buffer'; +import { pipeline, type Readable } from 'node:stream'; +import { noop } from '../util/util'; +import { SILENCE_FRAME, type AudioPlayer } from './AudioPlayer'; +import { findPipeline, StreamType, TransformerType, type Edge } from './TransformerGraph'; +import { OggDemuxer, OpusDecoder, OpusEncoder, WebmDemuxer } from '@discord-player/opus'; +import { VolumeTransformer } from '@discord-player/equalizer'; + +/** + * Options that are set when creating a new audio resource. + * + * @typeParam Metadata - the type for the metadata (if any) of the audio resource + */ +export interface CreateAudioResourceOptions { + /** + * Whether or not inline volume should be enabled. If enabled, you will be able to change the volume + * of the stream on-the-fly. However, this also increases the performance cost of playback. Defaults to `false`. + */ + inlineVolume?: boolean; + + /** + * The type of the input stream. Defaults to `StreamType.Arbitrary`. + */ + inputType?: StreamType; + + /** + * Optional metadata that can be attached to the resource (e.g. track title, random id). + * This is useful for identification purposes when the resource is passed around in events. + * See {@link AudioResource.metadata} + */ + metadata?: Metadata; + + /** + * The number of silence frames to append to the end of the resource's audio stream, to prevent interpolation glitches. + * Defaults to 5. + */ + silencePaddingFrames?: number; +} + +/** + * Represents an audio resource that can be played by an audio player. + * + * @typeParam Metadata - the type for the metadata (if any) of the audio resource + */ +export class AudioResource { + /** + * An object-mode Readable stream that emits Opus packets. This is what is played by audio players. + */ + public readonly playStream: Readable; + + /** + * The pipeline used to convert the input stream into a playable format. For example, this may + * contain an FFmpeg component for arbitrary inputs, and it may contain a VolumeTransformer component + * for resources with inline volume transformation enabled. + */ + public readonly edges: readonly Edge[]; + + /** + * Optional metadata that can be used to identify the resource. + */ + public metadata: Metadata; + + /** + * If the resource was created with inline volume transformation enabled, then this will be a + * prism-media VolumeTransformer. You can use this to alter the volume of the stream. + */ + public readonly volume?: VolumeTransformer; + + /** + * If using an Opus encoder to create this audio resource, then this will be a prism-media opus.Encoder. + * You can use this to control settings such as bitrate, FEC, PLP. + */ + public readonly encoder?: OpusEncoder; + + /** + * The audio player that the resource is subscribed to, if any. + */ + public audioPlayer?: AudioPlayer | undefined; + + /** + * The playback duration of this audio resource, given in milliseconds. + */ + public playbackDuration = 0; + + /** + * Whether or not the stream for this resource has started (data has become readable) + */ + public started = false; + + /** + * The number of silence frames to append to the end of the resource's audio stream, to prevent interpolation glitches. + */ + public readonly silencePaddingFrames: number; + + /** + * The number of remaining silence frames to play. If -1, the frames have not yet started playing. + */ + public silenceRemaining = -1; + + public constructor(edges: readonly Edge[], streams: readonly Readable[], metadata: Metadata, silencePaddingFrames: number) { + this.edges = edges; + this.playStream = streams.length > 1 ? (pipeline(streams, noop) as unknown as Readable) : streams[0]!; + this.metadata = metadata; + this.silencePaddingFrames = silencePaddingFrames; + + for (const stream of streams) { + if (stream instanceof VolumeTransformer) { + this.volume = stream; + } else if (stream instanceof OpusEncoder) { + this.encoder = stream; + } + } + + this.playStream.once('readable', () => (this.started = true)); + } + + /** + * Whether this resource is readable. If the underlying resource is no longer readable, this will still return true + * while there are silence padding frames left to play. + */ + public get readable() { + if (this.silenceRemaining === 0) return false; + const real = this.playStream.readable; + if (!real) { + if (this.silenceRemaining === -1) this.silenceRemaining = this.silencePaddingFrames; + return this.silenceRemaining !== 0; + } + + return real; + } + + /** + * Whether this resource has ended or not. + */ + public get ended() { + return this.playStream.readableEnded || this.playStream.destroyed || this.silenceRemaining === 0; + } + + /** + * Attempts to read an Opus packet from the audio resource. If a packet is available, the playbackDuration + * is incremented. + * + * @remarks + * It is advisable to check that the playStream is readable before calling this method. While no runtime + * errors will be thrown, you should check that the resource is still available before attempting to + * read from it. + * @internal + */ + public read(): Buffer | null { + if (this.silenceRemaining === 0) { + return null; + } else if (this.silenceRemaining > 0) { + this.silenceRemaining--; + return SILENCE_FRAME; + } + + const packet = this.playStream.read() as Buffer | null; + if (packet) { + this.playbackDuration += 20; + } + + return packet; + } +} + +/** + * Ensures that a path contains at least one volume transforming component. + * + * @param path - The path to validate constraints on + */ +export const VOLUME_CONSTRAINT = (path: Edge[]) => path.some((edge) => edge.type === TransformerType.InlineVolume); + +export const NO_CONSTRAINT = () => true; + +/** + * Tries to infer the type of a stream to aid with transcoder pipelining. + * + * @param stream - The stream to infer the type of + */ +export function inferStreamType(stream: Readable): { + hasVolume: boolean; + streamType: StreamType; +} { + if (stream instanceof OpusEncoder) { + return { streamType: StreamType.Opus, hasVolume: false }; + } else if (stream instanceof OpusDecoder) { + return { streamType: StreamType.Raw, hasVolume: false }; + } else if (stream instanceof VolumeTransformer) { + return { streamType: StreamType.Raw, hasVolume: true }; + } else if (stream instanceof OggDemuxer) { + return { streamType: StreamType.Opus, hasVolume: false }; + } else if (stream instanceof WebmDemuxer) { + return { streamType: StreamType.Opus, hasVolume: false }; + } + + return { streamType: StreamType.Arbitrary, hasVolume: false }; +} + +/** + * Creates an audio resource that can be played by audio players. + * + * @remarks + * If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used. + * + * If the input is not in the correct format, then a pipeline of transcoders and transformers will be created + * to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg, + * Opus transcoders, and Ogg/WebM demuxers. + * @param input - The resource to play + * @param options - Configurable options for creating the resource + * @typeParam Metadata - the type for the metadata (if any) of the audio resource + */ +export function createAudioResource( + input: Readable | string, + options: CreateAudioResourceOptions & Pick : Required>, 'metadata'> +): AudioResource; + +/** + * Creates an audio resource that can be played by audio players. + * + * @remarks + * If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used. + * + * If the input is not in the correct format, then a pipeline of transcoders and transformers will be created + * to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg, + * Opus transcoders, and Ogg/WebM demuxers. + * @param input - The resource to play + * @param options - Configurable options for creating the resource + * @typeParam Metadata - the type for the metadata (if any) of the audio resource + */ +export function createAudioResource(input: Readable | string, options?: Omit, 'metadata'>): AudioResource; + +/** + * Creates an audio resource that can be played by audio players. + * + * @remarks + * If the input is given as a string, then the inputType option will be overridden and FFmpeg will be used. + * + * If the input is not in the correct format, then a pipeline of transcoders and transformers will be created + * to ensure that the resultant stream is in the correct format for playback. This could involve using FFmpeg, + * Opus transcoders, and Ogg/WebM demuxers. + * @param input - The resource to play + * @param options - Configurable options for creating the resource + * @typeParam Metadata - the type for the metadata (if any) of the audio resource + */ +export function createAudioResource(input: Readable | string, options: CreateAudioResourceOptions = {}): AudioResource { + let inputType = options.inputType; + let needsInlineVolume = Boolean(options.inlineVolume); + + // string inputs can only be used with FFmpeg + if (typeof input === 'string') { + inputType = StreamType.Arbitrary; + } else if (inputType === undefined) { + const analysis = inferStreamType(input); + inputType = analysis.streamType; + needsInlineVolume = needsInlineVolume && !analysis.hasVolume; + } + + const transformerPipeline = findPipeline(inputType, needsInlineVolume ? VOLUME_CONSTRAINT : NO_CONSTRAINT); + + if (transformerPipeline.length === 0) { + if (typeof input === 'string') throw new Error(`Invalid pipeline constructed for string resource '${input}'`); + // No adjustments required + return new AudioResource([], [input], (options.metadata ?? null) as Metadata, options.silencePaddingFrames ?? 5); + } + + const streams = transformerPipeline.map((edge) => edge.transformer(input)); + if (typeof input !== 'string') streams.unshift(input); + + return new AudioResource(transformerPipeline, streams, (options.metadata ?? null) as Metadata, options.silencePaddingFrames ?? 5); +} diff --git a/packages/discord-voip/src/audio/PlayerSubscription.ts b/packages/discord-voip/src/audio/PlayerSubscription.ts new file mode 100644 index 0000000000..73e00b841d --- /dev/null +++ b/packages/discord-voip/src/audio/PlayerSubscription.ts @@ -0,0 +1,36 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/* eslint-disable @typescript-eslint/dot-notation */ +import type { VoiceConnection } from '../VoiceConnection'; +import type { AudioPlayer } from './AudioPlayer'; + +/** + * Represents a subscription of a voice connection to an audio player, allowing + * the audio player to play audio on the voice connection. + */ +export class PlayerSubscription { + /** + * The voice connection of this subscription. + */ + public readonly connection: VoiceConnection; + + /** + * The audio player of this subscription. + */ + public readonly player: AudioPlayer; + + public constructor(connection: VoiceConnection, player: AudioPlayer) { + this.connection = connection; + this.player = player; + } + + /** + * Unsubscribes the connection from the audio player, meaning that the + * audio player cannot stream audio to it until a new subscription is made. + */ + public unsubscribe() { + this.connection['onSubscriptionRemoved'](this); + this.player['unsubscribe'](this); + } +} diff --git a/packages/discord-voip/src/audio/TransformerGraph.ts b/packages/discord-voip/src/audio/TransformerGraph.ts new file mode 100644 index 0000000000..1f12e21268 --- /dev/null +++ b/packages/discord-voip/src/audio/TransformerGraph.ts @@ -0,0 +1,271 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import type { Readable } from 'node:stream'; +import { OpusEncoder, OpusDecoder, OggDemuxer, WebmDemuxer } from '@discord-player/opus'; +import { createFFmpegArgs, FFmpeg } from '@discord-player/ffmpeg'; +import { VolumeTransformer } from '@discord-player/equalizer'; + +const FFMPEG_PCM_ARGUMENTS = createFFmpegArgs({ + analyzeduration: '0', + loglevel: '0', + f: 's16le', + ar: '48000', + ac: '2' +}); + +const FFMPEG_OPUS_ARGUMENTS = createFFmpegArgs({ + analyzeduration: '0', + loglevel: '0', + acodec: 'libopus', + f: 'opus', + ar: '48000', + ac: '2' +}); + +/** + * The different types of stream that can exist within the pipeline. + */ +export enum StreamType { + /** + * The type of the stream at this point is unknown. + */ + Arbitrary = 'arbitrary', + /** + * The stream at this point is Opus audio encoded in an Ogg wrapper. + */ + OggOpus = 'ogg/opus', + /** + * The stream at this point is Opus audio, and the stream is in object-mode. This is ready to play. + */ + Opus = 'opus', + /** + * The stream at this point is s16le PCM. + */ + Raw = 'raw', + /** + * The stream at this point is Opus audio encoded in a WebM wrapper. + */ + WebmOpus = 'webm/opus' +} + +/** + * The different types of transformers that can exist within the pipeline. + */ +export enum TransformerType { + FFmpegOgg = 'ffmpeg ogg', + FFmpegPCM = 'ffmpeg pcm', + InlineVolume = 'volume transformer', + OggOpusDemuxer = 'ogg/opus demuxer', + OpusDecoder = 'opus decoder', + OpusEncoder = 'opus encoder', + WebmOpusDemuxer = 'webm/opus demuxer' +} + +/** + * Represents a pathway from one stream type to another using a transformer. + */ +export interface Edge { + cost: number; + from: Node; + to: Node; + transformer(input: Readable | string): Readable; + type: TransformerType; +} + +/** + * Represents a type of stream within the graph, e.g. an Opus stream, or a stream of raw audio. + */ +export class Node { + /** + * The outbound edges from this node. + */ + public readonly edges: Edge[] = []; + + /** + * The type of stream for this node. + */ + public readonly type: StreamType; + + public constructor(type: StreamType) { + this.type = type; + } + + /** + * Creates an outbound edge from this node. + * + * @param edge - The edge to create + */ + public addEdge(edge: Omit) { + this.edges.push({ ...edge, from: this }); + } +} + +// Create a node for each stream type +let NODES: Map | null = null; + +function canEnableFFmpegOptimizations(): boolean { + return FFmpeg.resolveSafe()?.result.includes('--enable-libopus') === true; +} + +/** + * Gets a node from its stream type. + * + * @param type - The stream type of the target node + */ +export function getNode(type: StreamType) { + const node = (NODES ??= initializeNodes()).get(type); + if (!node) throw new Error(`Node type '${type}' does not exist!`); + return node; +} + +function initializeNodes(): Map { + const nodes = new Map(); + for (const streamType of Object.values(StreamType)) { + nodes.set(streamType, new Node(streamType)); + } + + nodes.get(StreamType.Raw)!.addEdge({ + type: TransformerType.OpusEncoder, + to: nodes.get(StreamType.Opus)!, + cost: 1.5, + transformer: () => new OpusEncoder({ rate: 48_000, channels: 2, frameSize: 960 }) + }); + + nodes.get(StreamType.Opus)!.addEdge({ + type: TransformerType.OpusDecoder, + to: nodes.get(StreamType.Raw)!, + cost: 1.5, + transformer: () => new OpusDecoder({ rate: 48_000, channels: 2, frameSize: 960 }) + }); + + nodes.get(StreamType.OggOpus)!.addEdge({ + type: TransformerType.OggOpusDemuxer, + to: nodes.get(StreamType.Opus)!, + cost: 1, + transformer: () => new OggDemuxer() + }); + + nodes.get(StreamType.WebmOpus)!.addEdge({ + type: TransformerType.WebmOpusDemuxer, + to: nodes.get(StreamType.Opus)!, + cost: 1, + transformer: () => new WebmDemuxer() + }); + + const FFMPEG_PCM_EDGE: Omit = { + type: TransformerType.FFmpegPCM, + to: nodes.get(StreamType.Raw)!, + cost: 2, + transformer: (input) => + new FFmpeg({ + args: ['-i', typeof input === 'string' ? input : '-', ...FFMPEG_PCM_ARGUMENTS] + }) + }; + + nodes.get(StreamType.Arbitrary)!.addEdge(FFMPEG_PCM_EDGE); + nodes.get(StreamType.OggOpus)!.addEdge(FFMPEG_PCM_EDGE); + nodes.get(StreamType.WebmOpus)!.addEdge(FFMPEG_PCM_EDGE); + + nodes.get(StreamType.Raw)!.addEdge({ + type: TransformerType.InlineVolume, + to: nodes.get(StreamType.Raw)!, + cost: 0.5, + transformer: () => new VolumeTransformer({ type: 's16le' }) + }); + + if (canEnableFFmpegOptimizations()) { + const FFMPEG_OGG_EDGE: Omit = { + type: TransformerType.FFmpegOgg, + to: nodes.get(StreamType.OggOpus)!, + cost: 2, + transformer: (input) => + new FFmpeg({ + args: ['-i', typeof input === 'string' ? input : '-', ...FFMPEG_OPUS_ARGUMENTS] + }) + }; + nodes.get(StreamType.Arbitrary)!.addEdge(FFMPEG_OGG_EDGE); + // Include Ogg and WebM as well in case they have different sampling rates or are mono instead of stereo + // at the moment, this will not do anything. However, if/when detection for correct Opus headers is + // implemented, this will help inform the voice engine that it is able to transcode the audio. + nodes.get(StreamType.OggOpus)!.addEdge(FFMPEG_OGG_EDGE); + nodes.get(StreamType.WebmOpus)!.addEdge(FFMPEG_OGG_EDGE); + } + + return nodes; +} + +/** + * Represents a step in the path from node A to node B. + */ +interface Step { + /** + * The cost of the steps after this step. + */ + cost: number; + + /** + * The edge associated with this step. + */ + edge?: Edge; + + /** + * The next step. + */ + next?: Step; +} + +/** + * Finds the shortest cost path from node A to node B. + * + * @param from - The start node + * @param constraints - Extra validation for a potential solution. Takes a path, returns true if the path is valid + * @param goal - The target node + * @param path - The running path + * @param depth - The number of remaining recursions + */ +function findPath(from: Node, constraints: (path: Edge[]) => boolean, goal = getNode(StreamType.Opus), path: Edge[] = [], depth = 5): Step { + if (from === goal && constraints(path)) { + return { cost: 0 }; + } else if (depth === 0) { + return { cost: Number.POSITIVE_INFINITY }; + } + + let currentBest: Step | undefined; + for (const edge of from.edges) { + if (currentBest && edge.cost > currentBest.cost) continue; + const next = findPath(edge.to, constraints, goal, [...path, edge], depth - 1); + const cost = edge.cost + next.cost; + if (!currentBest || cost < currentBest.cost) { + currentBest = { cost, edge, next }; + } + } + + return currentBest ?? { cost: Number.POSITIVE_INFINITY }; +} + +/** + * Takes the solution from findPath and assembles it into a list of edges. + * + * @param step - The first step of the path + */ +function constructPipeline(step: Step) { + const edges: Edge[] = []; + let current: Step | undefined = step; + while (current?.edge) { + edges.push(current.edge); + current = current.next; + } + + return edges; +} + +/** + * Finds the lowest-cost pipeline to convert the input stream type into an Opus stream. + * + * @param from - The stream type to start from + * @param constraint - Extra constraints that may be imposed on potential solution + */ +export function findPipeline(from: StreamType, constraint: (path: Edge[]) => boolean) { + return constructPipeline(findPath(getNode(from), constraint)); +} diff --git a/packages/discord-voip/src/audio/index.ts b/packages/discord-voip/src/audio/index.ts new file mode 100644 index 0000000000..06bb1044a1 --- /dev/null +++ b/packages/discord-voip/src/audio/index.ts @@ -0,0 +1,23 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +export { + AudioPlayer, + AudioPlayerStatus, + type AudioPlayerState, + NoSubscriberBehavior, + createAudioPlayer, + type AudioPlayerBufferingState, + type AudioPlayerIdleState, + type AudioPlayerPausedState, + type AudioPlayerPlayingState, + type CreateAudioPlayerOptions, +} from './AudioPlayer'; + +export { AudioPlayerError } from './AudioPlayerError'; + +export { AudioResource, type CreateAudioResourceOptions, createAudioResource } from './AudioResource'; + +export { PlayerSubscription } from './PlayerSubscription'; + +export { StreamType } from './TransformerGraph'; diff --git a/packages/discord-voip/src/common/types.ts b/packages/discord-voip/src/common/types.ts new file mode 100644 index 0000000000..d29b6e7ae7 --- /dev/null +++ b/packages/discord-voip/src/common/types.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type unsafe = any; diff --git a/packages/discord-voip/src/index.ts b/packages/discord-voip/src/index.ts new file mode 100644 index 0000000000..b3dfe6d252 --- /dev/null +++ b/packages/discord-voip/src/index.ts @@ -0,0 +1,26 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +export * from './joinVoiceChannel'; +export * from './audio/index'; +export * from './util/index'; + +export { + VoiceConnection, + type VoiceConnectionState, + VoiceConnectionStatus, + type VoiceConnectionConnectingState, + type VoiceConnectionDestroyedState, + type VoiceConnectionDisconnectedState, + type VoiceConnectionDisconnectedBaseState, + type VoiceConnectionDisconnectedOtherState, + type VoiceConnectionDisconnectedWebSocketState, + VoiceConnectionDisconnectReason, + type VoiceConnectionReadyState, + type VoiceConnectionSignallingState +} from './VoiceConnection'; + +export { type JoinConfig, getVoiceConnection, getVoiceConnections, getGroups } from './DataStore'; + +// eslint-disable-next-line @typescript-eslint/no-inferrable-types +export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/discord-voip/src/joinVoiceChannel.ts b/packages/discord-voip/src/joinVoiceChannel.ts new file mode 100644 index 0000000000..761d51506c --- /dev/null +++ b/packages/discord-voip/src/joinVoiceChannel.ts @@ -0,0 +1,68 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import type { JoinConfig } from './DataStore'; +import { createVoiceConnection } from './VoiceConnection'; +import type { DiscordGatewayAdapterCreator } from './util/adapter'; + +/** + * The options that can be given when creating a voice connection. + */ +export interface CreateVoiceConnectionOptions { + adapterCreator: DiscordGatewayAdapterCreator; + + /** + * If true, debug messages will be enabled for the voice connection and its + * related components. Defaults to false. + */ + debug?: boolean | undefined; +} + +/** + * 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; + + /** + * An optional group identifier for the voice connection. + */ + group?: 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; +} + +/** + * Creates a VoiceConnection to a Discord voice channel. + * + * @param options - the options for joining the voice channel + */ +export function joinVoiceChannel(options: CreateVoiceConnectionOptions & JoinVoiceChannelOptions) { + const joinConfig: JoinConfig = { + selfDeaf: true, + selfMute: false, + group: 'default', + ...options, + }; + + return createVoiceConnection(joinConfig, { + adapterCreator: options.adapterCreator, + debug: options.debug, + }); +} diff --git a/packages/discord-voip/src/networking/Networking.ts b/packages/discord-voip/src/networking/Networking.ts new file mode 100644 index 0000000000..92acd5a153 --- /dev/null +++ b/packages/discord-voip/src/networking/Networking.ts @@ -0,0 +1,612 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/* eslint-disable id-length */ +/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-declaration-merging */ +import { Buffer } from 'node:buffer'; +import { EventEmitter } from 'node:events'; +import crypto from 'node:crypto'; +import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import type { CloseEvent } from 'ws'; +import * as secretbox from '../util/Secretbox'; +import { noop } from '../util/util'; +import { VoiceUDPSocket } from './VoiceUDPSocket'; +import { VoiceWebSocket } from './VoiceWebSocket'; +import { unsafe } from '../common/types'; + +// The number of audio channels required by Discord +const CHANNELS = 2; +const TIMESTAMP_INC = (48_000 / 100) * CHANNELS; +const MAX_NONCE_SIZE = 2 ** 32 - 1; + +export const SUPPORTED_ENCRYPTION_MODES = ['aead_aes256_gcm_rtpsize', 'aead_xchacha20_poly1305_rtpsize']; + +/** + * The different statuses that a networking instance can hold. The order + * of the states between OpeningWs and Ready is chronological (first the + * instance enters OpeningWs, then it enters Identifying etc.) + */ +export enum NetworkingStatusCode { + OpeningWs, + Identifying, + UdpHandshaking, + SelectingProtocol, + Ready, + Resuming, + Closed +} + +/** + * The initial Networking state. Instances will be in this state when a WebSocket connection to a Discord + * voice gateway is being opened. + */ +export interface NetworkingOpeningWsState { + code: NetworkingStatusCode.OpeningWs; + connectionOptions: ConnectionOptions; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when it is attempting to authorize itself. + */ +export interface NetworkingIdentifyingState { + code: NetworkingStatusCode.Identifying; + connectionOptions: ConnectionOptions; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when opening a UDP connection to the IP and port provided + * by Discord, as well as performing IP discovery. + */ +export interface NetworkingUdpHandshakingState { + code: NetworkingStatusCode.UdpHandshaking; + connectionData: Pick; + connectionOptions: ConnectionOptions; + udp: VoiceUDPSocket; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when selecting an encryption protocol for audio packets. + */ +export interface NetworkingSelectingProtocolState { + code: NetworkingStatusCode.SelectingProtocol; + connectionData: Pick; + connectionOptions: ConnectionOptions; + udp: VoiceUDPSocket; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when it has a fully established connection to a Discord + * voice server. + */ +export interface NetworkingReadyState { + code: NetworkingStatusCode.Ready; + connectionData: ConnectionData; + connectionOptions: ConnectionOptions; + preparedPacket?: Buffer | undefined; + udp: VoiceUDPSocket; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when its connection has been dropped unexpectedly, and it + * is attempting to resume an existing session. + */ +export interface NetworkingResumingState { + code: NetworkingStatusCode.Resuming; + connectionData: ConnectionData; + connectionOptions: ConnectionOptions; + preparedPacket?: Buffer | undefined; + udp: VoiceUDPSocket; + ws: VoiceWebSocket; +} + +/** + * The state that a Networking instance will be in when it has been destroyed. It cannot be recovered from this + * state. + */ +export interface NetworkingClosedState { + code: NetworkingStatusCode.Closed; +} + +/** + * The various states that a networking instance can be in. + */ +export type NetworkingState = + | NetworkingClosedState + | NetworkingIdentifyingState + | NetworkingOpeningWsState + | NetworkingReadyState + | NetworkingResumingState + | NetworkingSelectingProtocolState + | NetworkingUdpHandshakingState; + +/** + * Details required to connect to the Discord voice gateway. These details + * are first received on the main bot gateway, in the form of VOICE_SERVER_UPDATE + * and VOICE_STATE_UPDATE packets. + */ +interface ConnectionOptions { + endpoint: string; + serverId: string; + sessionId: string; + token: string; + userId: string; +} + +/** + * Information about the current connection, e.g. which encryption mode is to be used on + * the connection, timing information for playback of streams. + */ +export interface ConnectionData { + encryptionMode: string; + nonce: number; + nonceBuffer: Buffer; + packetsPlayed: number; + secretKey: Uint8Array; + sequence: number; + speaking: boolean; + ssrc: number; + timestamp: number; +} + +/** + * An empty buffer that is reused in packet encryption by many different networking instances. + */ +const nonce = Buffer.alloc(24); + +export interface Networking extends EventEmitter { + /** + * Debug event for Networking. + * + * @eventProperty + */ + on(event: 'debug', listener: (message: string) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'stateChange', listener: (oldState: NetworkingState, newState: NetworkingState) => void): this; + on(event: 'close', listener: (code: number) => void): this; +} + +/** + * Stringifies a NetworkingState. + * + * @param state - The state to stringify + */ +function stringifyState(state: NetworkingState) { + return JSON.stringify({ + ...state, + ws: Reflect.has(state, 'ws'), + udp: Reflect.has(state, 'udp') + }); +} + +/** + * Chooses an encryption mode from a list of given options. Chooses the most preferred option. + * + * @param options - The available encryption options + */ +function chooseEncryptionMode(options: string[]): string { + const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option)); + if (!option) { + throw new Error(`No compatible encryption modes. Available include: ${options.join(', ')}`); + } + + return option; +} + +/** + * Returns a random number that is in the range of n bits. + * + * @param numberOfBits - The number of bits + */ +function randomNBit(numberOfBits: number) { + return Math.floor(Math.random() * 2 ** numberOfBits); +} + +/** + * Manages the networking required to maintain a voice connection and dispatch audio packets + */ +export class Networking extends EventEmitter { + private _state: NetworkingState; + + /** + * The debug logger function, if debugging is enabled. + */ + private readonly debug: ((message: string) => void) | null; + + /** + * Creates a new Networking instance. + */ + public constructor(options: ConnectionOptions, debug: boolean) { + super(); + + this.onWsOpen = this.onWsOpen.bind(this); + this.onChildError = this.onChildError.bind(this); + this.onWsPacket = this.onWsPacket.bind(this); + this.onWsClose = this.onWsClose.bind(this); + this.onWsDebug = this.onWsDebug.bind(this); + this.onUdpDebug = this.onUdpDebug.bind(this); + this.onUdpClose = this.onUdpClose.bind(this); + + this.debug = debug ? (message: string) => this.emit('debug', message) : null; + + this._state = { + code: NetworkingStatusCode.OpeningWs, + ws: this.createWebSocket(options.endpoint), + connectionOptions: options + }; + } + + /** + * Destroys the Networking instance, transitioning it into the Closed state. + */ + public destroy() { + this.state = { + code: NetworkingStatusCode.Closed + }; + } + + /** + * The current state of the networking instance. + */ + public get state(): NetworkingState { + return this._state; + } + + /** + * Sets a new state for the networking instance, performing clean-up operations where necessary. + */ + public set state(newState: NetworkingState) { + const oldWs = Reflect.get(this._state, 'ws') as VoiceWebSocket | undefined; + const newWs = Reflect.get(newState, 'ws') as VoiceWebSocket | undefined; + if (oldWs && oldWs !== newWs) { + // The old WebSocket is being freed - remove all handlers from it + oldWs.off('debug', this.onWsDebug); + oldWs.on('error', noop); + oldWs.off('error', this.onChildError); + oldWs.off('open', this.onWsOpen); + oldWs.off('packet', this.onWsPacket); + oldWs.off('close', this.onWsClose); + oldWs.destroy(); + } + + const oldUdp = Reflect.get(this._state, 'udp') as VoiceUDPSocket | undefined; + const newUdp = Reflect.get(newState, 'udp') as VoiceUDPSocket | undefined; + + if (oldUdp && oldUdp !== newUdp) { + oldUdp.on('error', noop); + oldUdp.off('error', this.onChildError); + oldUdp.off('close', this.onUdpClose); + oldUdp.off('debug', this.onUdpDebug); + oldUdp.destroy(); + } + + const oldState = this._state; + this._state = newState; + this.emit('stateChange', oldState, newState); + + this.debug?.(`state change:\nfrom ${stringifyState(oldState)}\nto ${stringifyState(newState)}`); + } + + /** + * Creates a new WebSocket to a Discord Voice gateway. + * + * @param endpoint - The endpoint to connect to + */ + private createWebSocket(endpoint: string) { + const ws = new VoiceWebSocket(`wss://${endpoint}?v=4`, Boolean(this.debug)); + + ws.on('error', this.onChildError); + ws.once('open', this.onWsOpen); + ws.on('packet', this.onWsPacket); + ws.once('close', this.onWsClose); + ws.on('debug', this.onWsDebug); + + return ws; + } + + /** + * Propagates errors from the children VoiceWebSocket and VoiceUDPSocket. + * + * @param error - The error that was emitted by a child + */ + private onChildError(error: Error) { + this.emit('error', error); + } + + /** + * Called when the WebSocket opens. Depending on the state that the instance is in, + * it will either identify with a new session, or it will attempt to resume an existing session. + */ + private onWsOpen() { + if (this.state.code === NetworkingStatusCode.OpeningWs) { + const packet = { + op: VoiceOpcodes.Identify, + d: { + server_id: this.state.connectionOptions.serverId, + user_id: this.state.connectionOptions.userId, + session_id: this.state.connectionOptions.sessionId, + token: this.state.connectionOptions.token + } + }; + this.state.ws.sendPacket(packet); + this.state = { + ...this.state, + code: NetworkingStatusCode.Identifying + }; + } else if (this.state.code === NetworkingStatusCode.Resuming) { + const packet = { + op: VoiceOpcodes.Resume, + d: { + server_id: this.state.connectionOptions.serverId, + session_id: this.state.connectionOptions.sessionId, + token: this.state.connectionOptions.token + } + }; + this.state.ws.sendPacket(packet); + } + } + + /** + * Called when the WebSocket closes. Based on the reason for closing (given by the code parameter), + * the instance will either attempt to resume, or enter the closed state and emit a 'close' event + * with the close code, allowing the user to decide whether or not they would like to reconnect. + * + * @param code - The close code + */ + private onWsClose({ code }: CloseEvent) { + const canResume = code === 4_015 || code < 4_000; + if (canResume && this.state.code === NetworkingStatusCode.Ready) { + this.state = { + ...this.state, + code: NetworkingStatusCode.Resuming, + ws: this.createWebSocket(this.state.connectionOptions.endpoint) + }; + } else if (this.state.code !== NetworkingStatusCode.Closed) { + this.destroy(); + this.emit('close', code); + } + } + + /** + * Called when the UDP socket has closed itself if it has stopped receiving replies from Discord. + */ + private onUdpClose() { + if (this.state.code === NetworkingStatusCode.Ready) { + this.state = { + ...this.state, + code: NetworkingStatusCode.Resuming, + ws: this.createWebSocket(this.state.connectionOptions.endpoint) + }; + } + } + + /** + * Called when a packet is received on the connection's WebSocket. + * + * @param packet - The received packet + */ + private onWsPacket(packet: unsafe) { + if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) { + this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval); + } else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) { + const { ip, port, ssrc, modes } = packet.d; + + const udp = new VoiceUDPSocket({ ip, port }); + udp.on('error', this.onChildError); + udp.on('debug', this.onUdpDebug); + udp.once('close', this.onUdpClose); + udp.performIPDiscovery(ssrc) + .then((localConfig) => { + if (this.state.code !== NetworkingStatusCode.UdpHandshaking) return; + this.state.ws.sendPacket({ + op: VoiceOpcodes.SelectProtocol, + d: { + protocol: 'udp', + data: { + address: localConfig.ip, + port: localConfig.port, + mode: chooseEncryptionMode(modes) + } + } + }); + this.state = { + ...this.state, + code: NetworkingStatusCode.SelectingProtocol + }; + }) + .catch((error: Error) => this.emit('error', error)); + + this.state = { + ...this.state, + code: NetworkingStatusCode.UdpHandshaking, + udp, + connectionData: { + ssrc + } + }; + } else if (packet.op === VoiceOpcodes.SessionDescription && this.state.code === NetworkingStatusCode.SelectingProtocol) { + const { mode: encryptionMode, secret_key: secretKey } = packet.d; + this.state = { + ...this.state, + code: NetworkingStatusCode.Ready, + connectionData: { + ...this.state.connectionData, + encryptionMode, + secretKey: new Uint8Array(secretKey), + sequence: randomNBit(16), + timestamp: randomNBit(32), + nonce: 0, + nonceBuffer: encryptionMode === 'aead_aes256_gcm_rtpsize' ? Buffer.alloc(12) : Buffer.alloc(24), + speaking: false, + packetsPlayed: 0 + } + }; + } else if (packet.op === VoiceOpcodes.Resumed && this.state.code === NetworkingStatusCode.Resuming) { + this.state = { + ...this.state, + code: NetworkingStatusCode.Ready + }; + this.state.connectionData.speaking = false; + } + } + + /** + * Propagates debug messages from the child WebSocket. + * + * @param message - The emitted debug message + */ + private onWsDebug(message: string) { + this.debug?.(`[WS] ${message}`); + } + + /** + * Propagates debug messages from the child UDPSocket. + * + * @param message - The emitted debug message + */ + private onUdpDebug(message: string) { + this.debug?.(`[UDP] ${message}`); + } + + /** + * Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it. + * It will be stored within the instance, and can be played by dispatchAudio() + * + * @remarks + * Calling this method while there is already a prepared audio packet that has not yet been dispatched + * will overwrite the existing audio packet. This should be avoided. + * @param opusPacket - The Opus packet to encrypt + * @returns The audio packet that was prepared + */ + public prepareAudioPacket(opusPacket: Buffer) { + const state = this.state; + if (state.code !== NetworkingStatusCode.Ready) return; + state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData); + return state.preparedPacket; + } + + /** + * Dispatches the audio packet previously prepared by prepareAudioPacket(opusPacket). The audio packet + * is consumed and cannot be dispatched again. + */ + public dispatchAudio() { + const state = this.state; + if (state.code !== NetworkingStatusCode.Ready) return false; + if (state.preparedPacket !== undefined) { + this.playAudioPacket(state.preparedPacket); + state.preparedPacket = undefined; + return true; + } + + return false; + } + + /** + * Plays an audio packet, updating timing metadata used for playback. + * + * @param audioPacket - The audio packet to play + */ + private playAudioPacket(audioPacket: Buffer) { + 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; + if (connectionData.timestamp >= 2 ** 32) connectionData.timestamp = 0; + this.setSpeaking(true); + state.udp.send(audioPacket); + } + + /** + * Sends a packet to the voice gateway indicating that the client has start/stopped sending + * audio. + * + * @param speaking - Whether or not the client should be shown as speaking + */ + public setSpeaking(speaking: boolean) { + const state = this.state; + if (state.code !== NetworkingStatusCode.Ready) return; + if (state.connectionData.speaking === speaking) return; + state.connectionData.speaking = speaking; + state.ws.sendPacket({ + op: VoiceOpcodes.Speaking, + d: { + speaking: speaking ? 1 : 0, + delay: 0, + ssrc: state.connectionData.ssrc + } + }); + } + + /** + * Creates a new audio packet from an Opus packet. This involves encrypting the packet, + * then prepending a header that includes metadata. + * + * @param opusPacket - The Opus packet to prepare + * @param connectionData - The current connection data of the instance + */ + private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) { + const packetBuffer = Buffer.alloc(12); + packetBuffer[0] = 0x80; + packetBuffer[1] = 0x78; + + const { sequence, timestamp, ssrc } = connectionData; + + packetBuffer.writeUIntBE(sequence, 2, 2); + packetBuffer.writeUIntBE(timestamp, 4, 4); + packetBuffer.writeUIntBE(ssrc, 8, 4); + + // @ts-ignore + packetBuffer.copy(nonce, 0, 0, 12); + // @ts-ignore + return Buffer.concat([packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData, packetBuffer)]); + } + + /** + * Encrypts an Opus packet using the format agreed upon by the instance and Discord. + * + * @param opusPacket - The Opus packet to encrypt + * @param connectionData - The current connection data of the instance + */ + private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData, data: Buffer) { + const { secretKey, encryptionMode } = connectionData; + + // Both supported encryption methods want the nonce to be an incremental integer + connectionData.nonce++; + if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0; + connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0); + + // 4 extra bytes of padding on the end of the encrypted packet + const noncePadding = connectionData.nonceBuffer.slice(0, 4); + + let encrypted; + switch (encryptionMode) { + case 'aead_aes256_gcm_rtpsize': { + // @ts-ignore + const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, connectionData.nonceBuffer); + // @ts-ignore + cipher.setAAD(data); + + // @ts-ignore + encrypted = Buffer.concat([cipher.update(opusPacket), cipher.final(), cipher.getAuthTag()]); + + return [encrypted, noncePadding]; + } + case 'aead_xchacha20_poly1305_rtpsize': { + encrypted = secretbox.methods.crypto_aead_xchacha20poly1305_ietf_encrypt(opusPacket, data, connectionData.nonceBuffer, secretKey); + + return [encrypted, noncePadding]; + } + default: { + // This should never happen. Our encryption mode is chosen from a list given to us by the gateway and checked with the ones we support. + throw new RangeError(`Unsupported encryption method: ${encryptionMode}`); + } + } + } +} diff --git a/packages/discord-voip/src/networking/VoiceUDPSocket.ts b/packages/discord-voip/src/networking/VoiceUDPSocket.ts new file mode 100644 index 0000000000..5a8fd71530 --- /dev/null +++ b/packages/discord-voip/src/networking/VoiceUDPSocket.ts @@ -0,0 +1,182 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ + +import { Buffer } from 'node:buffer'; +import { createSocket, type Socket } from 'node:dgram'; +import { EventEmitter } from 'node:events'; +import { isIPv4 } from 'node:net'; + +/** + * Stores an IP address and port. Used to store socket details for the local client as well as + * for Discord. + */ +export interface SocketConfig { + ip: string; + port: number; +} + +/** + * Parses the response from Discord to aid with local IP discovery. + * + * @param message - The received message + */ +export function parseLocalPacket(message: Buffer): SocketConfig { + const packet = Buffer.from(message); + + const ip = packet.slice(8, packet.indexOf(0, 8)).toString('utf8'); + + if (!isIPv4(ip)) { + throw new Error('Malformed IP address'); + } + + const port = packet.readUInt16BE(packet.length - 2); + + return { ip, port }; +} + +/** + * The interval in milliseconds at which keep alive datagrams are sent. + */ +const KEEP_ALIVE_INTERVAL = 5e3; + +/** + * The maximum value of the keep alive counter. + */ +const MAX_COUNTER_VALUE = 2 ** 32 - 1; + +export interface VoiceUDPSocket extends EventEmitter { + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'close', listener: () => void): this; + on(event: 'debug', listener: (message: string) => void): this; + on(event: 'message', listener: (message: Buffer) => void): this; +} + +/** + * Manages the UDP networking for a voice connection. + */ +export class VoiceUDPSocket extends EventEmitter { + /** + * The underlying network Socket for the VoiceUDPSocket. + */ + private readonly socket: Socket; + + /** + * The socket details for Discord (remote) + */ + private readonly remote: SocketConfig; + + /** + * The counter used in the keep alive mechanism. + */ + private keepAliveCounter = 0; + + /** + * The buffer used to write the keep alive counter into. + */ + private readonly keepAliveBuffer: Buffer; + + /** + * The Node.js interval for the keep-alive mechanism. + */ + private readonly keepAliveInterval: NodeJS.Timeout; + + /** + * The time taken to receive a response to keep alive messages. + * + * @deprecated This field is no longer updated as keep alive messages are no longer tracked. + */ + public ping?: number; + + /** + * Creates a new VoiceUDPSocket. + * + * @param remote - Details of the remote socket + */ + public constructor(remote: SocketConfig) { + super(); + this.socket = createSocket('udp4'); + this.socket.on('error', (error: Error) => this.emit('error', error)); + this.socket.on('message', (buffer: Buffer) => this.onMessage(buffer)); + this.socket.on('close', () => this.emit('close')); + this.remote = remote; + this.keepAliveBuffer = Buffer.alloc(8); + this.keepAliveInterval = setInterval(() => this.keepAlive(), KEEP_ALIVE_INTERVAL); + setImmediate(() => this.keepAlive()); + } + + /** + * Called when a message is received on the UDP socket. + * + * @param buffer - The received buffer + */ + private onMessage(buffer: Buffer): void { + // Propagate the message + this.emit('message', buffer); + } + + /** + * Called at a regular interval to check whether we are still able to send datagrams to Discord. + */ + private keepAlive() { + this.keepAliveBuffer.writeUInt32LE(this.keepAliveCounter, 0); + this.send(this.keepAliveBuffer); + this.keepAliveCounter++; + if (this.keepAliveCounter > MAX_COUNTER_VALUE) { + this.keepAliveCounter = 0; + } + } + + /** + * Sends a buffer to Discord. + * + * @param buffer - The buffer to send + */ + public send(buffer: Buffer) { + // @ts-ignore + this.socket.send(buffer, this.remote.port, this.remote.ip); + } + + /** + * Closes the socket, the instance will not be able to be reused. + */ + public destroy() { + try { + this.socket.close(); + } catch { + // + } + + clearInterval(this.keepAliveInterval); + } + + /** + * Performs IP discovery to discover the local address and port to be used for the voice connection. + * + * @param ssrc - The SSRC received from Discord + */ + public async performIPDiscovery(ssrc: number): Promise { + return new Promise((resolve, reject) => { + const listener = (message: Buffer) => { + try { + if (message.readUInt16BE(0) !== 2) return; + const packet = parseLocalPacket(message); + this.socket.off('message', listener); + resolve(packet); + } catch { + // + } + }; + + this.socket.on('message', listener); + this.socket.once('close', () => reject(new Error('Cannot perform IP discovery - socket closed'))); + + const discoveryBuffer = Buffer.alloc(74); + + discoveryBuffer.writeUInt16BE(1, 0); + discoveryBuffer.writeUInt16BE(70, 2); + discoveryBuffer.writeUInt32BE(ssrc, 4); + this.send(discoveryBuffer); + }); + } +} diff --git a/packages/discord-voip/src/networking/VoiceWebSocket.ts b/packages/discord-voip/src/networking/VoiceWebSocket.ts new file mode 100644 index 0000000000..2ddf479ec4 --- /dev/null +++ b/packages/discord-voip/src/networking/VoiceWebSocket.ts @@ -0,0 +1,182 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ + +import { EventEmitter } from 'node:events'; +import { VoiceOpcodes } from 'discord-api-types/voice/v4'; +import WebSocket, { type MessageEvent } from 'ws'; +import { unsafe } from '../common/types'; + +export interface VoiceWebSocket extends EventEmitter { + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'open', listener: (event: WebSocket.Event) => void): this; + on(event: 'close', listener: (event: WebSocket.CloseEvent) => void): this; + /** + * Debug event for VoiceWebSocket. + * + * @eventProperty + */ + on(event: 'debug', listener: (message: string) => void): this; + /** + * Packet event. + * + * @eventProperty + */ + on(event: 'packet', listener: (packet: unsafe) => void): this; +} + +/** + * An extension of the WebSocket class to provide helper functionality when interacting + * with the Discord Voice gateway. + */ +export class VoiceWebSocket extends EventEmitter { + /** + * The current heartbeat interval, if any. + */ + private heartbeatInterval?: NodeJS.Timeout; + + /** + * The time (milliseconds since UNIX epoch) that the last heartbeat acknowledgement packet was received. + * This is set to 0 if an acknowledgement packet hasn't been received yet. + */ + private lastHeartbeatAck: number; + + /** + * The time (milliseconds since UNIX epoch) that the last heartbeat was sent. This is set to 0 if a heartbeat + * hasn't been sent yet. + */ + private lastHeartbeatSend: number; + + /** + * The number of consecutively missed heartbeats. + */ + private missedHeartbeats = 0; + + /** + * The last recorded ping. + */ + public ping?: number; + + /** + * The debug logger function, if debugging is enabled. + */ + private readonly debug: ((message: string) => void) | null; + + /** + * The underlying WebSocket of this wrapper. + */ + private readonly ws: WebSocket; + + /** + * Creates a new VoiceWebSocket. + * + * @param address - The address to connect to + */ + public constructor(address: string, debug: boolean) { + super(); + this.ws = new WebSocket(address); + this.ws.onmessage = (err) => this.onMessage(err); + this.ws.onopen = (err) => this.emit('open', err); + this.ws.onerror = (err: Error | WebSocket.ErrorEvent) => this.emit('error', err instanceof Error ? err : err.error); + this.ws.onclose = (err) => this.emit('close', err); + + this.lastHeartbeatAck = 0; + this.lastHeartbeatSend = 0; + + this.debug = debug ? (message: string) => this.emit('debug', message) : null; + } + + /** + * Destroys the VoiceWebSocket. The heartbeat interval is cleared, and the connection is closed. + */ + public destroy() { + try { + this.debug?.('destroyed'); + this.setHeartbeatInterval(-1); + this.ws.close(1_000); + } catch (error) { + const err = error as Error; + this.emit('error', err); + } + } + + /** + * Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them + * as packets. + * + * @param event - The message event + */ + public onMessage(event: MessageEvent) { + if (typeof event.data !== 'string') return; + + this.debug?.(`<< ${event.data}`); + + let packet: unsafe; + try { + packet = JSON.parse(event.data); + } catch (error) { + const err = error as Error; + this.emit('error', err); + return; + } + + if (packet.op === VoiceOpcodes.HeartbeatAck) { + this.lastHeartbeatAck = Date.now(); + this.missedHeartbeats = 0; + this.ping = this.lastHeartbeatAck - this.lastHeartbeatSend; + } + + this.emit('packet', packet); + } + + /** + * Sends a JSON-stringifiable packet over the WebSocket. + * + * @param packet - The packet to send + */ + public sendPacket(packet: unsafe) { + try { + const stringified = JSON.stringify(packet); + this.debug?.(`>> ${stringified}`); + this.ws.send(stringified); + } catch (error) { + const err = error as Error; + this.emit('error', err); + } + } + + /** + * Sends a heartbeat over the WebSocket. + */ + private sendHeartbeat() { + this.lastHeartbeatSend = Date.now(); + this.missedHeartbeats++; + const nonce = this.lastHeartbeatSend; + this.sendPacket({ + op: VoiceOpcodes.Heartbeat, + // eslint-disable-next-line id-length + d: nonce + }); + } + + /** + * Sets/clears an interval to send heartbeats over the WebSocket. + * + * @param ms - The interval in milliseconds. If negative, the interval will be unset + */ + public setHeartbeatInterval(ms: number) { + if (this.heartbeatInterval !== undefined) clearInterval(this.heartbeatInterval); + if (ms > 0) { + this.heartbeatInterval = setInterval(() => { + if (this.lastHeartbeatSend !== 0 && this.missedHeartbeats >= 3) { + // Missed too many heartbeats - disconnect + this.ws.close(); + this.setHeartbeatInterval(-1); + } + + this.sendHeartbeat(); + }, ms); + } + } +} diff --git a/packages/discord-voip/src/networking/index.ts b/packages/discord-voip/src/networking/index.ts new file mode 100644 index 0000000000..bce5ebbf04 --- /dev/null +++ b/packages/discord-voip/src/networking/index.ts @@ -0,0 +1,6 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +export * from './Networking'; +export * from './VoiceUDPSocket'; +export * from './VoiceWebSocket'; diff --git a/packages/discord-voip/src/util/Secretbox.ts b/packages/discord-voip/src/util/Secretbox.ts new file mode 100644 index 0000000000..f1563adf38 --- /dev/null +++ b/packages/discord-voip/src/util/Secretbox.ts @@ -0,0 +1,68 @@ +import { Buffer } from 'node:buffer'; + +interface Methods { + crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike): Buffer; +} + +const libs = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'sodium-native': (sodium: any): Methods => ({ + crypto_aead_xchacha20poly1305_ietf_encrypt: (plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike) => { + const cipherText = Buffer.alloc(plaintext.length + sodium.crypto_aead_xchacha20poly1305_ietf_ABYTES); + sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(cipherText, plaintext, additionalData, null, nonce, key); + return cipherText; + } + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sodium: (sodium: any): Methods => ({ + crypto_aead_xchacha20poly1305_ietf_encrypt: (plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike) => { + return sodium.api.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, null, nonce, key); + } + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'libsodium-wrappers': (sodium: any): Methods => ({ + crypto_aead_xchacha20poly1305_ietf_encrypt: (plaintext: Buffer, additionalData: Buffer, nonce: Buffer, key: ArrayBufferLike) => { + return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, additionalData, null, nonce, key); + } + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + '@stablelib/xchacha20poly1305': (stablelib: any): Methods => ({ + crypto_aead_xchacha20poly1305_ietf_encrypt(cipherText, additionalData, nonce, key) { + const crypto = new stablelib.XChaCha20Poly1305(key); + return crypto.seal(nonce, cipherText, additionalData); + } + }) +} as const; + +// @ts-ignore +libs['sodium-javascript'] = libs['sodium-native']; + +const validLibs = Object.keys(libs); + +const fallbackError = () => { + throw new Error( + `Cannot play audio as no valid encryption package is installed. +- Install one of the following packages: ${validLibs.join(', ')} +- Use the generateDependencyReport() function for more information.\n` + ); +}; + +const methods: Methods = { + crypto_aead_xchacha20poly1305_ietf_encrypt: fallbackError +}; + +void (async () => { + for (const libName of Object.keys(libs) as (keyof typeof libs)[]) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const lib = await import(libName); + if (libName === 'libsodium-wrappers' && lib.ready) await lib.ready; + Object.assign(methods, libs[libName](lib)); + break; + } catch { + // + } + } +})(); + +export { methods }; diff --git a/packages/discord-voip/src/util/abortAfter.ts b/packages/discord-voip/src/util/abortAfter.ts new file mode 100644 index 0000000000..1e7c570e90 --- /dev/null +++ b/packages/discord-voip/src/util/abortAfter.ts @@ -0,0 +1,14 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +/** + * Creates an abort controller that aborts after the given time. + * + * @param delay - The time in milliseconds to wait before aborting + */ +export function abortAfter(delay: number): [AbortController, AbortSignal] { + const ac = new AbortController(); + const timeout = setTimeout(() => ac.abort(), delay); + ac.signal.addEventListener('abort', () => clearTimeout(timeout)); + return [ac, ac.signal]; +} diff --git a/packages/discord-voip/src/util/adapter.ts b/packages/discord-voip/src/util/adapter.ts new file mode 100644 index 0000000000..bba68320e5 --- /dev/null +++ b/packages/discord-voip/src/util/adapter.ts @@ -0,0 +1,56 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import type { GatewayVoiceServerUpdateDispatchData, GatewayVoiceStateUpdateDispatchData } from 'discord-api-types/v10'; +import { unsafe } from '../common/types'; + +/** + * Methods that are provided by the \@discordjs/voice library to implementations of + * Discord gateway DiscordGatewayAdapters. + */ +export interface DiscordGatewayAdapterLibraryMethods { + /** + * Call this when the adapter can no longer be used (e.g. due to a disconnect from the main gateway) + */ + destroy(): void; + /** + * Call this when you receive a VOICE_SERVER_UPDATE payload that is relevant to the adapter. + * + * @param data - The inner data of the VOICE_SERVER_UPDATE payload + */ + onVoiceServerUpdate(data: GatewayVoiceServerUpdateDispatchData): void; + /** + * Call this when you receive a VOICE_STATE_UPDATE payload that is relevant to the adapter. + * + * @param data - The inner data of the VOICE_STATE_UPDATE payload + */ + onVoiceStateUpdate(data: GatewayVoiceStateUpdateDispatchData): void; +} + +/** + * Methods that are provided by the implementer of a Discord gateway DiscordGatewayAdapter. + */ +export interface DiscordGatewayAdapterImplementerMethods { + /** + * This will be called by \@discordjs/voice when the adapter can safely be destroyed as it will no + * longer be used. + */ + destroy(): void; + /** + * Implement this method such that the given payload is sent to the main Discord gateway connection. + * + * @param payload - The payload to send to the main Discord gateway connection + * @returns `false` if the payload definitely failed to send - in this case, the voice connection disconnects + */ + sendPayload(payload: unsafe): boolean; +} + +/** + * A function used to build adapters. It accepts a methods parameter that contains functions that + * can be called by the implementer when new data is received on its gateway connection. In return, + * the implementer will return some methods that the library can call - e.g. to send messages on + * the gateway, or to signal that the adapter can be removed. + */ +export type DiscordGatewayAdapterCreator = ( + methods: DiscordGatewayAdapterLibraryMethods, +) => DiscordGatewayAdapterImplementerMethods; diff --git a/packages/discord-voip/src/util/entersState.ts b/packages/discord-voip/src/util/entersState.ts new file mode 100644 index 0000000000..aab90f8398 --- /dev/null +++ b/packages/discord-voip/src/util/entersState.ts @@ -0,0 +1,58 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +import { type EventEmitter, once } from 'node:events'; +import type { VoiceConnection, VoiceConnectionStatus } from '../VoiceConnection'; +import type { AudioPlayer, AudioPlayerStatus } from '../audio/AudioPlayer'; +import { abortAfter } from './abortAfter'; + +/** + * Allows a voice connection a specified amount of time to enter a given state, otherwise rejects with an error. + * + * @param target - The voice connection that we want to observe the state change for + * @param status - The status that the voice connection should be in + * @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation + */ +export function entersState( + target: VoiceConnection, + status: VoiceConnectionStatus, + timeoutOrSignal: AbortSignal | number, +): Promise; + +/** + * Allows an audio player a specified amount of time to enter a given state, otherwise rejects with an error. + * + * @param target - The audio player that we want to observe the state change for + * @param status - The status that the audio player should be in + * @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation + */ +export function entersState( + target: AudioPlayer, + status: AudioPlayerStatus, + timeoutOrSignal: AbortSignal | number, +): Promise; + +/** + * Allows a target a specified amount of time to enter a given state, otherwise rejects with an error. + * + * @param target - The object that we want to observe the state change for + * @param status - The status that the target should be in + * @param timeoutOrSignal - The maximum time we are allowing for this to occur, or a signal that will abort the operation + */ +export async function entersState( + target: Target, + status: AudioPlayerStatus | VoiceConnectionStatus, + timeoutOrSignal: AbortSignal | number, +) { + if (target.state.status !== status) { + const [ac, signal] = + typeof timeoutOrSignal === 'number' ? abortAfter(timeoutOrSignal) : [undefined, timeoutOrSignal]; + try { + await once(target as EventEmitter, status, { signal }); + } finally { + ac?.abort(); + } + } + + return target; +} diff --git a/packages/discord-voip/src/util/index.ts b/packages/discord-voip/src/util/index.ts new file mode 100644 index 0000000000..4ff3f4f2b5 --- /dev/null +++ b/packages/discord-voip/src/util/index.ts @@ -0,0 +1,5 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +export * from './entersState'; +export * from './adapter'; diff --git a/packages/discord-voip/src/util/util.ts b/packages/discord-voip/src/util/util.ts new file mode 100644 index 0000000000..003b05928f --- /dev/null +++ b/packages/discord-voip/src/util/util.ts @@ -0,0 +1,4 @@ +// Copyright discord-player authors. All rights reserved. MIT License. +// Copyright discord.js authors. All rights reserved. Apache License 2.0 + +export const noop = () => {}; diff --git a/packages/voice/tsconfig.json b/packages/discord-voip/tsconfig.json similarity index 56% rename from packages/voice/tsconfig.json rename to packages/discord-voip/tsconfig.json index 3c774c914c..f1f1679fa3 100644 --- a/packages/voice/tsconfig.json +++ b/packages/discord-voip/tsconfig.json @@ -1,6 +1,4 @@ { "extends": "@discord-player/tsconfig/base.json", - "include": [ - "src/**/*" - ] -} \ No newline at end of file + "include": ["src/**/*"] +} diff --git a/packages/adapter-local/tsup.config.ts b/packages/discord-voip/tsup.config.ts similarity index 68% rename from packages/adapter-local/tsup.config.ts rename to packages/discord-voip/tsup.config.ts index e161ba4d4d..e5cc13c162 100644 --- a/packages/adapter-local/tsup.config.ts +++ b/packages/discord-voip/tsup.config.ts @@ -2,5 +2,6 @@ import { defineConfig } from '../../tsup.config'; import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; export default defineConfig({ - esbuildPlugins: [esbuildPluginVersionInjector()] + esbuildPlugins: [esbuildPluginVersionInjector()], + format: ['cjs'], }); diff --git a/packages/discord-voip/typedoc.json b/packages/discord-voip/typedoc.json new file mode 100644 index 0000000000..b3eddf3024 --- /dev/null +++ b/packages/discord-voip/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": ["src/index.ts"], + "excludePrivate": true, + "excludeExternals": true +} diff --git a/packages/downloader/LICENSE b/packages/downloader/LICENSE deleted file mode 100644 index fe07fc7364..0000000000 --- a/packages/downloader/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Androz2091 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/downloader/README.md b/packages/downloader/README.md deleted file mode 100644 index e8f7ff5209..0000000000 --- a/packages/downloader/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Downloader -Extractor for **[discord-player](https://npmjs.com/package/discord-player)** using **[youtube-dl](https://npmjs.com/package/youtube-dl)**. - -# Installing - -```sh -npm i @discord-player/downloader -``` - -# Example -## General -```js -const downloader = require("@discord-player/downloader").Downloader; -const fs = require("fs"); -const url = "https://soundcloud.com/dogesounds/alan-walker-feat-k-391-ignite"; - -const stream = downloader.download(url); -stream.pipe(fs.createWriteStream("./song.mp3")); -``` - -## With Discord Player -```js -const downloader = require("@discord-player/downloader").Downloader; - -player.use("YOUTUBE_DL", downloader); // enables youtube-dl extractor for discord-player -``` \ No newline at end of file diff --git a/packages/downloader/package.json b/packages/downloader/package.json deleted file mode 100644 index 28c0611b1b..0000000000 --- a/packages/downloader/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@discord-player/downloader", - "version": "3.0.2", - "description": "Stream extractor for discord-player via youtube-dl", - "keywords": [ - "discord-player", - "music", - "bot", - "discord.js", - "javascript", - "voip", - "lavalink", - "lavaplayer" - ], - "author": "Androz2091 ", - "homepage": "https://discord-player.js.org", - "license": "MIT", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/Androz2091/discord-player.git" - }, - "scripts": { - "build": "tsup", - "build:check": "tsc --noEmit", - "lint": "eslint src --ext .ts --fix" - }, - "bugs": { - "url": "https://github.com/Androz2091/discord-player/issues" - }, - "devDependencies": { - "@discord-player/tsconfig": "workspace:^", - "tsup": "^7.2.0", - "typescript": "^5.2.2", - "vitest": "^0.34.6" - }, - "dependencies": { - "youtube-dl-exec": "^2.1.11" - }, - "typedoc": { - "entryPoint": "./src/index.ts", - "readmeFile": "./README.md", - "tsconfig": "./tsconfig.json" - } -} diff --git a/packages/downloader/src/Downloader.ts b/packages/downloader/src/Downloader.ts deleted file mode 100644 index 621cb69bd8..0000000000 --- a/packages/downloader/src/Downloader.ts +++ /dev/null @@ -1,92 +0,0 @@ -import ytdl from 'youtube-dl-exec'; - -export interface Info { - title: string; - duration: number; - thumbnail: string; - views: number; - author: string; - description: string; - url: string; - source: string; - engine: import('stream').Readable; -} - -export class Downloader { - constructor() { - return Downloader; - } - - /** - * Downloads stream through youtube-dl - * @param {string} url URL to download stream from - */ - static download(url: string) { - if (!url || typeof url !== 'string') throw new Error('Invalid url'); - - const ytdlProcess = ytdl.exec(url, { - output: '-', - quiet: true, - preferFreeFormats: true, - limitRate: '100K' - }); - - if (!ytdlProcess.stdout) throw new Error('No stdout'); - const stream = ytdlProcess.stdout; - - stream.on('error', () => { - if (!ytdlProcess.killed) ytdlProcess.kill(); - stream.resume(); - }); - - return stream; - } - - /** - * Returns stream info - * @param {string} url stream url - */ - static getInfo(url: string) { - // eslint-disable-next-line - return new Promise<{ playlist: any; info: Info[] }>(async (resolve, reject) => { - if (!url || typeof url !== 'string') reject(new Error('Invalid url')); - - const info = await ytdl(url, { - dumpSingleJson: true, - skipDownload: true, - simulate: true - }).catch(() => undefined); - if (!info) return resolve({ playlist: null, info: [] }); - - try { - const data = { - title: info.fulltitle || info.title || 'Attachment', - duration: (info.duration || 0) * 1000, - thumbnail: info.thumbnails ? info.thumbnails[0].url : info.thumbnail || 'https://upload.wikimedia.org/wikipedia/commons/2/2a/ITunes_12.2_logo.png', - views: info.view_count || 0, - author: info.uploader || info.channel || 'YouTubeDL Media', - description: info.description || '', - url: url, - source: info.extractor, - get engine() { - return Downloader.download(url); - } - } as Info; - - resolve({ playlist: null, info: [data] }); - } catch { - resolve({ playlist: null, info: [] }); - } - }); - } - - static validate(url: string) { - const REGEX = - /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/; - return REGEX.test(url || ''); - } - - static get important() { - return true; - } -} diff --git a/packages/downloader/src/index.ts b/packages/downloader/src/index.ts deleted file mode 100644 index 4f98195ea1..0000000000 --- a/packages/downloader/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './Downloader'; -export * as ytdl from 'youtube-dl-exec'; - -// eslint-disable-next-line @typescript-eslint/no-inferrable-types -export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/downloader/tsconfig.json b/packages/downloader/tsconfig.json deleted file mode 100644 index 3c774c914c..0000000000 --- a/packages/downloader/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "@discord-player/tsconfig/base.json", - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/packages/downloader/tsup.config.ts b/packages/downloader/tsup.config.ts deleted file mode 100644 index e161ba4d4d..0000000000 --- a/packages/downloader/tsup.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from '../../tsup.config'; -import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; - -export default defineConfig({ - esbuildPlugins: [esbuildPluginVersionInjector()] -}); diff --git a/packages/equalizer/package.json b/packages/equalizer/package.json index 9f31b48bda..998dc2725a 100644 --- a/packages/equalizer/package.json +++ b/packages/equalizer/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/equalizer", - "version": "0.2.3", + "version": "7.0.0-dev.0", "description": "PCM Equalizer implementation for Discord Player", "keywords": [ "discord-player", @@ -20,6 +20,9 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, "directories": { "dist": "dist", "src": "src" diff --git a/packages/extractor/package.json b/packages/extractor/package.json index 6f97d08718..2a26360c20 100644 --- a/packages/extractor/package.json +++ b/packages/extractor/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/extractor", - "version": "4.6.0-dev.0", + "version": "7.0.0-dev.0", "description": "Extractors for discord-player", "keywords": [ "discord-player", @@ -18,6 +18,9 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, "files": [ "dist" ], @@ -62,4 +65,4 @@ "readmeFile": "./README.md", "tsconfig": "./tsconfig.json" } -} \ No newline at end of file +} diff --git a/packages/ffmpeg/package.json b/packages/ffmpeg/package.json index e1c0d9de0c..2d61f42b35 100644 --- a/packages/ffmpeg/package.json +++ b/packages/ffmpeg/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/ffmpeg", - "version": "0.1.0", + "version": "7.0.0-dev.0", "description": "FFmpeg stream abstraction for discord-player", "keywords": [ "discord-player", @@ -18,6 +18,9 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, "files": [ "dist" ], diff --git a/packages/ffmpeg/src/FFmpeg.ts b/packages/ffmpeg/src/FFmpeg.ts index 1cb4dc22d7..087113bed2 100644 --- a/packages/ffmpeg/src/FFmpeg.ts +++ b/packages/ffmpeg/src/FFmpeg.ts @@ -1,18 +1,20 @@ -import childProcess from 'child_process'; -import { Duplex, DuplexOptions } from 'stream'; +import { ChildProcessWithoutNullStreams, spawn, spawnSync } from 'node:child_process'; +import { Duplex, DuplexOptions } from 'node:stream'; -type Callback> = (...args: Args) => unknown; +export type FFmpegLib = 'ffmpeg' | './ffmpeg' | 'avconv' | './avconv' | 'ffmpeg-static' | '@ffmpeg-installer/ffmpeg' | '@node-ffmpeg/node-ffmpeg-installer' | 'ffmpeg-binaries'; -const validatePathParam = (t: unknown, name?: string) => { - if (typeof t !== 'string' || !t) throw new TypeError(`Expected ${name ? name.concat(' to be ') : ''}a string, got ${t}`); - return t; -}; +export type FFmpegCallback> = (...args: Args) => unknown; + +export interface FFmpegSource { + name: FFmpegLib; + module: boolean; +} -export interface FFmpegInfo { - command: string | null; - metadata: string | null; - version: string | null; - isStatic: boolean; +export interface ResolvedFFmpegSource extends FFmpegSource { + path: string; + version: string; + command: string; + result: string; } export interface FFmpegOptions extends DuplexOptions { @@ -20,168 +22,135 @@ export interface FFmpegOptions extends DuplexOptions { shell?: boolean; } -const ffmpegInfo: FFmpegInfo = { - command: null, - metadata: null, - version: null, - isStatic: false -}; - -interface FFmpegLocation { - displayName: string; - getPath: () => string; -} +const VERSION_REGEX = /version (.+) Copyright/im; -const isWindows = process.platform === 'win32'; - -/* eslint-disable @typescript-eslint/no-var-requires */ -// prettier-ignore -export const FFmpegPossibleLocations: FFmpegLocation[] = [ - { - getPath() { - return validatePathParam(process.env.FFMPEG_PATH, this.displayName); - }, - displayName: 'spawn process.env.FFMPEG_PATH' - }, - { - getPath() { - return 'ffmpeg'; - }, - displayName: 'spawn ffmpeg' - }, - { - getPath() { - return 'avconv'; - }, - displayName: 'spawn avconv' - }, - { - getPath() { - const loc = './ffmpeg'; - if (isWindows) return loc.concat('.exe'); - return loc; - }, - displayName: 'spawn ./ffmpeg' - }, - { - getPath() { - const loc = './avconv'; - if (isWindows) return loc.concat('.exe'); - return loc; - }, - displayName: 'spawn ./avconv' - }, - { - getPath() { - const mod = require('@ffmpeg-installer/ffmpeg'); - return validatePathParam(mod.default?.path || mod.path || mod, this.displayName); - }, - displayName: 'require("@ffmpeg-installer/ffmpeg")' - }, - { - getPath() { - const mod = require('ffmpeg-static'); - return validatePathParam(mod.default?.path || mod.path || mod, this.displayName); - }, - displayName: 'require("ffmpeg-static")' - }, - { - getPath() { - const mod = require('@node-ffmpeg/node-ffmpeg-installer'); - return validatePathParam(mod.default?.path || mod.path || mod, this.displayName); - }, - displayName: 'require("@node-ffmpeg/node-ffmpeg-installer")' - }, - { - getPath() { - const mod = require('ffmpeg-binaries'); - return validatePathParam(mod.default || mod, this.displayName); - }, - displayName: 'require("ffmpeg-binaries")' - } -]; -/* eslint-enable @typescript-eslint/no-var-requires */ +const validatePathParam = (path: string, displayName: string) => { + if (!path) throw new Error(`Failed to resolve ${displayName}`); + return path; +}; export class FFmpeg extends Duplex { /** - * FFmpeg version regex + * Cached FFmpeg source. */ - public static VersionRegex = /version (.+) Copyright/im; - + private static cached: ResolvedFFmpegSource | null = null; /** - * Spawns ffmpeg process - * @param options Spawn options + * Supported FFmpeg sources. */ - public static spawn({ args = [] as string[], shell = false } = {}) { - if (!args.includes('-i')) args.unshift('-i', '-'); + public static sources: FFmpegSource[] = [ + // paths + { name: 'ffmpeg', module: false }, + { name: './ffmpeg', module: false }, + { name: 'avconv', module: false }, + { name: './avconv', module: false }, + // modules + { name: 'ffmpeg-static', module: true }, + { name: '@ffmpeg-installer/ffmpeg', module: true }, + { name: '@node-ffmpeg/node-ffmpeg-installer', module: true }, + { name: 'ffmpeg-binaries', module: true } + ]; - return childProcess.spawn(this.locate()!.command!, args.concat(['pipe:1']), { windowsHide: true, shell }); + /** + * Checks if FFmpeg is loaded. + */ + public static isLoaded() { + return FFmpeg.cached != null; } /** - * Check if ffmpeg is available + * Adds a new FFmpeg source. + * @param source FFmpeg source */ - public static isAvailable() { - return typeof this.locateSafe(false)?.command === 'string'; + public static addSource(source: FFmpegSource) { + if (FFmpeg.sources.some((s) => s.name === source.name)) return false; + FFmpeg.sources.push(source); + return true; } /** - * Safe locate ffmpeg - * @param force if it should relocate the command + * Removes a FFmpeg source. + * @param source FFmpeg source */ - public static locateSafe(force = false) { - try { - return this.locate(force); - } catch { - return null; - } + public static removeSource(source: FFmpegSource) { + const index = FFmpeg.sources.findIndex((s) => s.name === source.name); + if (index === -1) return false; + FFmpeg.sources.splice(index, 1); + return true; } /** - * Locate ffmpeg command. Throws error if ffmpeg is not found. - * @param force Forcefully reload + * Resolves FFmpeg path. Throws an error if it fails to resolve. + * @param force if it should relocate the command */ - public static locate(force = false): FFmpegInfo | undefined { - if (ffmpegInfo.command && !force) return ffmpegInfo; + public static resolve(force = false) { + if (!force && FFmpeg.cached) return FFmpeg.cached; - const errStacks: Error[] = new Array(FFmpegPossibleLocations.length); + const errors: string[] = []; - for (const locator of FFmpegPossibleLocations) { - if (locator == null) continue; + for (const source of FFmpeg.sources) { try { - const command = locator.getPath(); + let path: string; + + if (source.module) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(source.name); + path = validatePathParam(mod.default?.path || mod.path || mod, source.name); + } else { + path = source.name; + } + + const result = spawnSync(path, ['-v'], { windowsHide: true }); - const result = childProcess.spawnSync(command, ['-h'], { - windowsHide: true - }); + const resolved: ResolvedFFmpegSource = { + result: result.stdout.toString(), + command: path, + module: source.module, + name: source.name, + path, + version: VERSION_REGEX.exec(result.stderr.toString())?.[1] ?? 'unknown' + }; - if (result.error) throw result.error; + FFmpeg.cached = resolved; - ffmpegInfo.command = command; - ffmpegInfo.metadata = Buffer.concat(result.output.filter(Boolean) as Buffer[]).toString(); - ffmpegInfo.isStatic = locator.displayName.startsWith('require("'); - ffmpegInfo.version = FFmpeg.VersionRegex.exec(ffmpegInfo.metadata || '')?.[1] || null; + errors.length = 0; - return ffmpegInfo; + return resolved; } catch (e) { - errStacks.push(e as Error); + const err = e && e instanceof Error ? e.message : `${e}`; + const msg = `Failed to load ffmpeg using ${source.module ? `require('${source.name}')` : `spawn('${source.name}')`}. Error: ${err}`; + + errors.push(msg); } } - // prettier-ignore - throw new Error([ - 'Could not locate ffmpeg. Tried:\n', - ...FFmpegPossibleLocations.map((loc, i) => ` ${++i}. ${loc.displayName}`), - '\n', - `${'='.repeat(5)}Full Stacktrace${'='.repeat(5)}`, - ...errStacks.map((e) => e.stack || e.message) - ].join('\n')); + throw new Error(`Could not load ffmpeg. Errors:\n${errors.join('\n')}`); + } + + /** + * Resolves FFmpeg path safely. Returns null if it fails to resolve. + * @param force if it should relocate the command + */ + public static resolveSafe(force = false) { + try { + return FFmpeg.resolve(force); + } catch { + return null; + } + } + + /** + * Spawns ffmpeg process + * @param options Spawn options + */ + public static spawn({ args = [] as string[], shell = false } = {}) { + if (!args.includes('-i')) args.unshift('-i', '-'); + return spawn(FFmpeg.resolve().command, args.concat(['pipe:1']), { windowsHide: true, shell }); } /** * Current FFmpeg process */ - public process: childProcess.ChildProcessWithoutNullStreams; + public process: ChildProcessWithoutNullStreams; /** * Create FFmpeg duplex stream @@ -227,7 +196,9 @@ export class FFmpeg extends Duplex { for (const method of ['on', 'once', 'removeListener', 'removeAllListeners', 'listeners'] as const) { // @ts-expect-error - this[method] = (ev, fn) => (EVENTS[ev] ? EVENTS[ev][method](ev, fn) : Duplex.prototype[method].call(this, ev, fn)); + this[method] = (ev, fn) => + // @ts-expect-error + EVENTS[ev] ? EVENTS[ev][method](ev, fn) : Duplex.prototype[method].call(this, ev, fn); } const processError = (error: Error) => this.emit('error', error); @@ -250,12 +221,12 @@ export class FFmpeg extends Duplex { } } - public _destroy(err: Error | null, cb: Callback<[Error | null]>) { + public _destroy(err: Error | null, cb: FFmpegCallback<[Error | null]>) { this._cleanup(); if (cb) return cb(err); } - public _final(cb: Callback<[]>) { + public _final(cb: FFmpegCallback<[]>) { this._cleanup(); cb(); } @@ -266,15 +237,7 @@ export class FFmpeg extends Duplex { // }); this.process.kill('SIGKILL'); - this.process = null as unknown as childProcess.ChildProcessWithoutNullStreams; + this.process = null as unknown as ChildProcessWithoutNullStreams; } } - - public toString() { - if (!ffmpegInfo.metadata) return 'FFmpeg'; - - return ffmpegInfo.metadata; - } } - -export const findFFmpeg = FFmpeg.locate; diff --git a/packages/ffmpeg/src/index.ts b/packages/ffmpeg/src/index.ts index 5db16ec9b9..7b6cb01bd7 100644 --- a/packages/ffmpeg/src/index.ts +++ b/packages/ffmpeg/src/index.ts @@ -1,4 +1,27 @@ export * from './FFmpeg'; +export type ArgPrimitive = string | number; + +/** + * Create FFmpeg arguments from an object. + * @param input The input object. + * @returns The FFmpeg arguments. + * @example createFFmpegArgs({ i: 'input.mp3', af: ['bass=g=10','acompressor'] }, './out.mp3'); + * // => ['-i', 'input.mp3', '-af', 'bass=g=10,acompressor', './out.mp3'] + */ +export const createFFmpegArgs = (input: Record, post?: string | string[]): string[] => { + const args = []; + + for (const [key, value] of Object.entries(input)) { + args.push(`-${key}`, String(value)); + } + + if (post) { + Array.isArray(post) ? args.push(...post) : args.push(post); + } + + return args; +}; + // eslint-disable-next-line @typescript-eslint/no-inferrable-types export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/opus/package.json b/packages/opus/package.json index eb8eaef069..ac3f76fd51 100644 --- a/packages/opus/package.json +++ b/packages/opus/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/opus", - "version": "0.1.2", + "version": "7.0.0-dev.0", "description": "A complete framework to simplify the implementation of music commands for Discord bots", "keywords": [ "discord-player", @@ -12,6 +12,9 @@ "lavalink", "lavaplayer" ], + "publishConfig": { + "access": "public" + }, "author": "Androz2091 ", "homepage": "https://discord-player.js.org", "license": "MIT", diff --git a/packages/opus/src/OggDemuxer.ts b/packages/opus/src/OggDemuxer.ts new file mode 100644 index 0000000000..ca67f9616a --- /dev/null +++ b/packages/opus/src/OggDemuxer.ts @@ -0,0 +1,134 @@ +// based on https://github.com/amishshah/prism-media/blob/4ef1d6f9f53042c085c1f68627e889003e248d77/src/opus/OggDemuxer.js + +import { Transform, TransformCallback } from 'node:stream'; + +const OGG_PAGE_HEADER_SIZE = 26; +const STREAM_STRUCTURE_VERSION = 0; + +const charCode = (x: string) => x.charCodeAt(0); +const OGGS_HEADER = Buffer.from([...'OggS'].map(charCode)); +const OPUS_HEAD = Buffer.from([...'OpusHead'].map(charCode)); +const OPUS_TAGS = Buffer.from([...'OpusTags'].map(charCode)); + +/** + * Demuxes an Ogg stream (containing Opus audio) to output an Opus stream. + */ +export class OggDemuxer extends Transform { + private _remainder: Buffer | null = null; + private _head: Buffer | null = null; + private _bitstream: number | null = null; + + /** + * Creates a new OggOpus demuxer. + * @param {Object} [options] options that you would pass to a regular Transform stream. + * @memberof opus + */ + public constructor(options = {}) { + super(Object.assign({ readableObjectMode: true }, options)); + this._remainder = null; + this._head = null; + this._bitstream = null; + } + + _transform(chunk: Buffer, encoding: BufferEncoding, done: TransformCallback) { + if (this._remainder) { + chunk = Buffer.concat([this._remainder, chunk]); + this._remainder = null; + } + + try { + while (chunk) { + const result = this._readPage(chunk); + if (result) chunk = result; + else break; + } + } catch (error) { + done(error as Error); + return; + } + + this._remainder = chunk; + done(); + } + + /** + * Reads a page from a buffer + * @private + * @param {Buffer} chunk the chunk containing the page + * @returns {boolean|Buffer} if a buffer, it will be a slice of the excess data of the original, otherwise it will be + * false and would indicate that there is not enough data to go ahead with reading this page. + */ + _readPage(chunk: Buffer) { + if (chunk.length < OGG_PAGE_HEADER_SIZE) { + return false; + } + if (!chunk.subarray(0, 4).equals(OGGS_HEADER)) { + throw Error(`capture_pattern is not ${OGGS_HEADER}`); + } + if (chunk.readUInt8(4) !== STREAM_STRUCTURE_VERSION) { + throw Error(`stream_structure_version is not ${STREAM_STRUCTURE_VERSION}`); + } + + if (chunk.length < 27) return false; + const pageSegments = chunk.readUInt8(26); + if (chunk.length < 27 + pageSegments) return false; + const table = chunk.subarray(27, 27 + pageSegments); + const bitstream = chunk.readUInt32BE(14); + + const sizes: number[] = []; + let totalSize = 0; + + for (let i = 0; i < pageSegments; ) { + let size = 0, + x = 255; + while (x === 255) { + if (i >= table.length) return false; + x = table.readUInt8(i); + i++; + size += x; + } + sizes.push(size); + totalSize += size; + } + + if (chunk.length < 27 + pageSegments + totalSize) return false; + + let start = 27 + pageSegments; + for (const size of sizes) { + const segment = chunk.subarray(start, start + size); + const header = segment.subarray(0, 8); + if (this._head) { + if (header.equals(OPUS_TAGS)) this.emit('tags', segment); + else if (this._bitstream === bitstream) this.push(segment); + } else if (header.equals(OPUS_HEAD)) { + this.emit('head', segment); + this._head = segment; + this._bitstream = bitstream; + } else { + this.emit('unknownSegment', segment); + } + start += size; + } + return chunk.subarray(start); + } + + _destroy(err: Error, cb: (error: Error | null) => void) { + this._cleanup(); + return cb ? cb(err) : undefined; + } + + _final(cb: TransformCallback) { + this._cleanup(); + cb(); + } + + /** + * Cleans up the demuxer when it is no longer required. + * @private + */ + _cleanup() { + this._remainder = null; + this._head = null; + this._bitstream = null; + } +} diff --git a/packages/opus/src/OpusEncoder.ts b/packages/opus/src/OpusEncoder.ts new file mode 100644 index 0000000000..0262bc3cb2 --- /dev/null +++ b/packages/opus/src/OpusEncoder.ts @@ -0,0 +1,379 @@ +// based on https://github.com/amishshah/prism-media/blob/4ef1d6f9f53042c085c1f68627e889003e248d77/src/opus/Opus.js + +import { Transform, type TransformCallback } from 'node:stream'; + +export type IEncoder = { + new (rate: number, channels: number, application: number): { + encode(buffer: Buffer): Buffer; + encode(buffer: Buffer, frameSize: number): Buffer; + encode(buffer: Buffer, frameSize?: number): Buffer; + decode(buffer: Buffer): Buffer; + decode(buffer: Buffer, frameSize: number): Buffer; + decode(buffer: Buffer, frameSize?: number): Buffer; + applyEncoderCTL?(ctl: number, value: number): void; + encoderCTL?(ctl: number, value: number): void; + delete?(): void; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Application?: any; +}; + +type IMod = [ + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mod: any) => { + Encoder: IEncoder; + } +]; + +const loadModule = ( + modules: IMod[] +): { + Encoder: IEncoder; + name: string; +} => { + const errors: string[] = []; + + for (const [name, fn] of modules) { + try { + return { + // eslint-disable-next-line @typescript-eslint/no-var-requires + ...fn(require(name)), + name + }; + } catch (e) { + errors.push(`Failed to load ${name}: ${e}`); + continue; + } + } + + throw new Error(`Could not load opus module, tried ${modules.length} different modules. Errors: ${errors.join('\n')}`); +}; + +export const CTL = { + BITRATE: 0xfa2, + FEC: 0xfac, + PLP: 0xfae +} as const; + +const OPUS_MOD_REGISTRY: IMod[] = [ + [ + 'mediaplex', + (mod) => { + if (!mod.OpusEncoder) throw new Error('Unsupported mediaplex version'); + return { Encoder: mod.OpusEncoder }; + } + ], + ['@discordjs/opus', (opus) => ({ Encoder: opus.OpusEncoder })], + ['opusscript', (opus) => ({ Encoder: opus })], + [ + '@evan/opus', + (opus) => { + const { Encoder, Decoder } = opus as typeof import('@evan/opus'); + + class OpusEncoder { + private _encoder!: InstanceType | null; + private _decoder!: InstanceType | null; + + public constructor(private _rate: number, private _channels: number, private _application: number) {} + + private _ensureEncoder() { + if (this._encoder) return; + this._encoder = new Encoder({ + channels: this._channels as 2, + sample_rate: this._rate as 48000, + application: ({ + 2048: 'voip', + 2049: 'audio', + 2051: 'restricted_lowdelay' + })[this._application] + }); + } + + private _ensureDecoder() { + if (this._decoder) return; + this._decoder = new Decoder({ + channels: this._channels as 2, + sample_rate: this._rate as 48000 + }); + } + + public encode(buffer: Buffer) { + this._ensureEncoder(); + return Buffer.from(this._encoder!.encode(buffer)); + } + + public decode(buffer: Buffer) { + this._ensureDecoder(); + return Buffer.from(this._decoder!.decode(buffer)); + } + + public applyEncoderCTL(ctl: number, value: number) { + this._ensureEncoder(); + this._encoder!.ctl(ctl, value); + } + + public delete() { + this._encoder = null; + this._decoder = null; + } + } + + return { Encoder: OpusEncoder }; + } + ], + ['node-opus', (opus) => ({ Encoder: opus.OpusEncoder })] +]; + +let Opus: { Encoder?: IEncoder; name?: string } = {}; + +/** + * Add a new Opus provider to the registry. This will be tried to load in order at runtime. + * @param provider - The provider to add + */ +export const addLibopusProvider = (provider: IMod) => { + if (OPUS_MOD_REGISTRY.some(([, fn]) => fn === provider[1])) return; + OPUS_MOD_REGISTRY.push(provider); +}; + +/** + * Remove an Opus provider from the registry. + * @param name - The name of the provider to remove + */ +export const removeLibopusProvider = (name: string) => { + const index = OPUS_MOD_REGISTRY.findIndex((o) => o[0] === name); + if (index === -1) return false; + OPUS_MOD_REGISTRY.splice(index, 1); + return true; +}; + +/** + * Set the Opus provider to use. This will override the automatic provider selection. + * @param provider - The provider to use + */ +export const setLibopusProvider = (provider: IEncoder, name: string) => { + Opus = { Encoder: provider, name }; +}; + +function loadOpus(refresh = false) { + if (Opus.Encoder && !refresh) return Opus; + + Opus = loadModule(OPUS_MOD_REGISTRY); + return Opus; +} + +const charCode = (x: string) => x.charCodeAt(0); +const OPUS_HEAD = Buffer.from([...'OpusHead'].map(charCode)); +const OPUS_TAGS = Buffer.from([...'OpusTags'].map(charCode)); + +export interface IOpusStreamInit { + frameSize: number; + channels: number; + rate: number; + application?: number; +} + +// frame size = (channels * rate * frame_duration) / 1000 + +/** + * Takes a stream of Opus data and outputs a stream of PCM data, or the inverse. + * **You shouldn't directly instantiate this class, see opus.Encoder and opus.Decoder instead!** + * @memberof opus + * @extends TransformStream + * @protected + */ +export class OpusStream extends Transform { + public encoder: InstanceType | null = null; + public _options: IOpusStreamInit; + public _required: number; + /** + * Creates a new Opus transformer. + * @private + * @memberof opus + * @param {Object} [options] options that you would pass to a regular Transform stream + */ + constructor(options = {} as IOpusStreamInit) { + if (!loadOpus().Encoder) { + throw Error(`Could not find an Opus module! Please install one of ${OPUS_MOD_REGISTRY.map((o) => o[0]).join(', ')}.`); + } + super(Object.assign({ readableObjectMode: true }, options)); + + const lib = Opus as Required; + + if (lib.name === 'opusscript') { + options.application = lib.Encoder.Application![options.application!]; + } + + this.encoder = new lib.Encoder(options.rate, options.channels, options.application!); + + this._options = options; + this._required = this._options.frameSize * this._options.channels * 2; + } + + _encode(buffer: Buffer) { + if (Opus.name === 'opusscript') { + return this.encoder!.encode(buffer, this._options.frameSize); + } else { + return this.encoder!.encode(buffer); + } + } + + _decode(buffer: Buffer) { + if (Opus.name === 'opusscript') { + return this.encoder!.decode(buffer, this._options.frameSize); + } else { + return this.encoder!.decode(buffer); + } + } + + /** + * Returns the Opus module being used - `mediaplex`, `opusscript`, `node-opus`, or `@discordjs/opus`. + * @type {string} + * @readonly + * @example + * console.log(`Using Opus module ${OpusEncoder.type}`); + */ + static get type() { + return Opus.name; + } + + /** + * Sets the bitrate of the stream. + * @param {number} bitrate the bitrate to use use, e.g. 48000 + * @public + */ + setBitrate(bitrate: number) { + (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.BITRATE, Math.min(128e3, Math.max(16e3, bitrate))]); + } + + /** + * Enables or disables forward error correction. + * @param {boolean} enabled whether or not to enable FEC. + * @public + */ + setFEC(enabled: boolean) { + (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.FEC, enabled ? 1 : 0]); + } + + /** + * Sets the expected packet loss over network transmission. + * @param {number} [percentage] a percentage (represented between 0 and 1) + */ + setPLP(percentage: number) { + (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.PLP, Math.min(100, Math.max(0, percentage * 100))]); + } + + _final(cb: () => void) { + this._cleanup(); + cb(); + } + + _destroy(err: Error | null, cb: (err: Error | null) => void) { + this._cleanup(); + return cb ? cb(err) : undefined; + } + + /** + * Cleans up the Opus stream when it is no longer needed + * @private + */ + _cleanup() { + if (typeof this.encoder?.delete === 'function') this.encoder!.delete!(); + this.encoder = null; + } +} + +/** + * An Opus encoder stream. + * + * Outputs opus packets in [object mode.](https://nodejs.org/api/stream.html#stream_object_mode) + * @extends opus.OpusStream + * @memberof opus + * @example + * const encoder = new prism.opus.Encoder({ frameSize: 960, channels: 2, rate: 48000 }); + * pcmAudio.pipe(encoder); + * // encoder will now output Opus-encoded audio packets + */ +export class OpusEncoder extends OpusStream { + _buffer: Buffer = Buffer.allocUnsafe(0); + + /** + * Creates a new Opus encoder stream. + * @memberof opus + * @param {Object} options options that you would pass to a regular OpusStream, plus a few more: + * @param {number} options.frameSize the frame size in bytes to use (e.g. 960 for stereo audio at 48KHz with a frame + * duration of 20ms) + * @param {number} options.channels the number of channels to use + * @param {number} options.rate the sampling rate in Hz + */ + constructor(options = {} as IOpusStreamInit) { + super(options); + } + + public _transform(newChunk: Buffer, encoding: BufferEncoding, done: TransformCallback): void { + const chunk = Buffer.concat([this._buffer, newChunk]); + + let i = 0; + while (chunk.length >= i + this._required) { + const pcm = chunk.slice(i, i + this._required); + let opus: Buffer | undefined; + try { + opus = this.encoder!.encode(pcm); + } catch (error) { + done(error as Error); + return; + } + this.push(opus); + i += this._required; + } + + if (i > 0) this._buffer = chunk.slice(i); + done(); + } + + _destroy(err: Error, cb: (err: Error | null) => void) { + super._destroy(err, cb); + this._buffer = Buffer.allocUnsafe(0); + } +} + +/** + * An Opus decoder stream. + * + * Note that any stream you pipe into this must be in + * [object mode](https://nodejs.org/api/stream.html#stream_object_mode) and should output Opus packets. + * @extends opus.OpusStream + * @memberof opus + * @example + * const decoder = new OpusDecoder({ frameSize: 960, channels: 2, rate: 48000 }); + * input.pipe(decoder); + * // decoder will now output PCM audio + */ +export class OpusDecoder extends OpusStream { + _transform(chunk: Buffer, encoding: BufferEncoding, done: (e?: Error | null, chunk?: Buffer) => void) { + const signature = chunk.slice(0, 8); + if (chunk.length >= 8 && signature.equals(OPUS_HEAD)) { + this.emit('format', { + channels: this._options.channels, + sampleRate: this._options.rate, + bitDepth: 16, + float: false, + signed: true, + version: chunk.readUInt8(8), + preSkip: chunk.readUInt16LE(10), + gain: chunk.readUInt16LE(16) + }); + return done(); + } + if (chunk.length >= 8 && signature.equals(OPUS_TAGS)) { + this.emit('tags', chunk); + return done(); + } + try { + this.push(this._decode(chunk)); + } catch (e) { + return done(e as Error); + } + return done(); + } +} diff --git a/packages/opus/src/WebmBase.ts b/packages/opus/src/WebmBase.ts new file mode 100644 index 0000000000..fdcf1ff5fe --- /dev/null +++ b/packages/opus/src/WebmBase.ts @@ -0,0 +1,220 @@ +// based on https://github.com/amishshah/prism-media/blob/4ef1d6f9f53042c085c1f68627e889003e248d77/src/core/WebmBase.js + +import { Transform, TransformCallback } from 'node:stream'; + +export class WebmBaseDemuxer extends Transform { + public static readonly TAGS = { + // value is true if the element has children + '1a45dfa3': true, // EBML + '18538067': true, // Segment + '1f43b675': true, // Cluster + '1654ae6b': true, // Tracks + ae: true, // TrackEntry + d7: false, // TrackNumber + '83': false, // TrackType + a3: false, // SimpleBlock + '63a2': false + }; + + public static readonly TOO_SHORT = Symbol('TOO_SHORT'); + + private _remainder: Buffer | null = null; + private _length = 0; + private _count = 0; + private _skipUntil: number | null = null; + private _track: { number: number; type: number } | null = null; + private _incompleteTrack: { number?: number; type?: number } = {}; + private _ebmlFound = false; + + /** + * Creates a new Webm demuxer. + * @param {Object} [options] options that you would pass to a regular Transform stream. + */ + constructor(options = {}) { + super(Object.assign({ readableObjectMode: true }, options)); + this._remainder = null; + this._length = 0; + this._count = 0; + this._skipUntil = null; + this._track = null; + this._incompleteTrack = {}; + this._ebmlFound = false; + } + + public _checkHead(data: Buffer) { + void data; + } + + _transform(chunk: Buffer, encoding: BufferEncoding, done: TransformCallback) { + this._length += chunk.length; + if (this._remainder) { + chunk = Buffer.concat([this._remainder, chunk]); + this._remainder = null; + } + let offset = 0; + if (this._skipUntil && this._length > this._skipUntil) { + offset = this._skipUntil - this._count; + this._skipUntil = null; + } else if (this._skipUntil) { + this._count += chunk.length; + done(); + return; + } + + let result; + // @ts-ignore + while (result !== WebmBaseDemuxer.TOO_SHORT) { + try { + result = this._readTag(chunk, offset); + } catch (error) { + done(error as Error); + return; + } + if (result === WebmBaseDemuxer.TOO_SHORT) break; + if (result._skipUntil) { + this._skipUntil = result._skipUntil; + break; + } + if (result.offset) offset = result.offset; + else break; + } + this._count += offset; + this._remainder = chunk.subarray(offset); + done(); + return; + } + + /** + * Reads an EBML ID from a buffer. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains an `id` property (buffer) and the new `offset` (number). + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readEBMLId(chunk: Buffer, offset: number) { + const idLength = vintLength(chunk, offset); + if (idLength === WebmBaseDemuxer.TOO_SHORT) return WebmBaseDemuxer.TOO_SHORT; + return { + id: chunk.subarray(offset, offset + idLength), + offset: offset + idLength + }; + } + + /** + * Reads a size variable-integer to calculate the length of the data of a tag. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains property `offset` (number), `dataLength` (number) and `sizeLength` (number). + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readTagDataSize(chunk: Buffer, offset: number) { + const sizeLength = vintLength(chunk, offset); + if (sizeLength === WebmBaseDemuxer.TOO_SHORT) return WebmBaseDemuxer.TOO_SHORT; + const dataLength = expandVint(chunk, offset, offset + sizeLength); + return { offset: offset + sizeLength, dataLength, sizeLength }; + } + + /** + * Takes a buffer and attempts to read and process a tag. + * @private + * @param {Buffer} chunk the buffer to read from. + * @param {number} offset the offset in the buffer. + * @returns {Object|Symbol} contains the new `offset` (number) and optionally the `_skipUntil` property, + * indicating that the stream should ignore any data until a certain length is reached. + * Returns the TOO_SHORT symbol if the data wasn't big enough to facilitate the request. + */ + _readTag(chunk: Buffer, offset: number) { + const idData = this._readEBMLId(chunk, offset); + if (idData === WebmBaseDemuxer.TOO_SHORT) return WebmBaseDemuxer.TOO_SHORT; + const ebmlID = idData.id.toString('hex'); + if (!this._ebmlFound) { + if (ebmlID === '1a45dfa3') this._ebmlFound = true; + else throw Error('Did not find the EBML tag at the start of the stream'); + } + offset = idData.offset; + const sizeData = this._readTagDataSize(chunk, offset); + if (sizeData === WebmBaseDemuxer.TOO_SHORT) return WebmBaseDemuxer.TOO_SHORT; + const { dataLength } = sizeData; + offset = sizeData.offset; + // If this tag isn't useful, tell the stream to stop processing data until the tag ends + if (typeof WebmBaseDemuxer.TAGS[ebmlID as keyof (typeof WebmBaseDemuxer)['TAGS']] === 'undefined') { + if (chunk.length > offset + (dataLength as number)) { + return { offset: offset + (dataLength as number) }; + } + return { offset, _skipUntil: this._count + (offset as number) + (dataLength as number) }; + } + + const tagHasChildren = WebmBaseDemuxer.TAGS[ebmlID as keyof (typeof WebmBaseDemuxer)['TAGS']]; + if (tagHasChildren) { + return { offset }; + } + + if ((offset as number) + (dataLength as number) > chunk.length) return WebmBaseDemuxer.TOO_SHORT; + const data = chunk.subarray(offset, (offset as number) + (dataLength as number)); + if (!this._track) { + if (ebmlID === 'ae') this._incompleteTrack = {}; + if (ebmlID === 'd7') this._incompleteTrack.number = data[0]; + if (ebmlID === '83') this._incompleteTrack.type = data[0]; + if (this._incompleteTrack.type === 2 && typeof this._incompleteTrack.number !== 'undefined') { + // @ts-ignore + this._track = this._incompleteTrack; + } + } + if (ebmlID === '63a2') { + this._checkHead(data); + this.emit('head', data); + } else if (ebmlID === 'a3') { + if (!this._track) throw Error('No audio track in this webm!'); + if ((data[0] & 0xf) === this._track.number) { + this.push(data.subarray(4)); + } + } + return { offset: (offset as number) + (dataLength as number) }; + } + + _destroy(err: Error, cb: (error: Error | null) => void) { + this._cleanup(); + return cb ? cb(err) : undefined; + } + + _final(cb: TransformCallback) { + this._cleanup(); + cb(); + } + + /** + * Cleans up the demuxer when it is no longer required. + * @private + */ + _cleanup() { + this._remainder = null; + this._incompleteTrack = {}; + } +} + +function vintLength(buffer: Buffer, index: number) { + if (index < 0 || index > buffer.length - 1) { + return WebmBaseDemuxer.TOO_SHORT; + } + let i = 0; + for (; i < 8; i++) if ((1 << (7 - i)) & buffer[index]) break; + i++; + if (index + i > buffer.length) { + return WebmBaseDemuxer.TOO_SHORT; + } + return i; +} + +function expandVint(buffer: Buffer, start: number, end: number) { + const length = vintLength(buffer, start); + if (end > buffer.length || length === WebmBaseDemuxer.TOO_SHORT) return WebmBaseDemuxer.TOO_SHORT; + // @ts-ignore + const mask = (1 << (8 - length)) - 1; + let value = buffer[start] & mask; + for (let i = start + 1; i < end; i++) { + value = (value << 8) + buffer[i]; + } + return value; +} diff --git a/packages/opus/src/WebmDemuxer.ts b/packages/opus/src/WebmDemuxer.ts new file mode 100644 index 0000000000..9ce21cb228 --- /dev/null +++ b/packages/opus/src/WebmDemuxer.ts @@ -0,0 +1,22 @@ +// based on https://github.com/amishshah/prism-media/blob/4ef1d6f9f53042c085c1f68627e889003e248d77/src/opus/WebmDemuxer.js + +import { WebmBaseDemuxer } from './WebmBase'; + +const OPUS_HEAD = Buffer.from([...'OpusHead'].map((x) => x.charCodeAt(0))); + +/** + * Demuxes a Webm stream (containing Opus audio) to output an Opus stream. + * @example + * const fs = require('fs'); + * const file = fs.createReadStream('./audio.webm'); + * const demuxer = new WebmDemuxer(); + * const opus = file.pipe(demuxer); + * // opus is now a ReadableStream in object mode outputting Opus packets + */ +export class WebmDemuxer extends WebmBaseDemuxer { + _checkHead(data: Buffer) { + if (!data.subarray(0, 8).equals(OPUS_HEAD)) { + throw Error('Audio codec is not Opus!'); + } + } +} diff --git a/packages/opus/src/index.ts b/packages/opus/src/index.ts index db8e7c3edd..6b6bbc94e2 100644 --- a/packages/opus/src/index.ts +++ b/packages/opus/src/index.ts @@ -1,354 +1,6 @@ -// based on https://github.com/amishshah/prism-media/blob/4ef1d6f9f53042c085c1f68627e889003e248d77/src/opus/Opus.js - -import { Transform, type TransformCallback } from 'stream'; - -export type IEncoder = { - new (rate: number, channels: number, application: number): { - encode(buffer: Buffer): Buffer; - encode(buffer: Buffer, frameSize: number): Buffer; - encode(buffer: Buffer, frameSize?: number): Buffer; - decode(buffer: Buffer): Buffer; - decode(buffer: Buffer, frameSize: number): Buffer; - decode(buffer: Buffer, frameSize?: number): Buffer; - applyEncoderCTL?(ctl: number, value: number): void; - encoderCTL?(ctl: number, value: number): void; - delete?(): void; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Application?: any; -}; - -type IMod = [ - string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mod: any) => { - Encoder: IEncoder; - } -]; - -const loadModule = ( - modules: IMod[] -): { - Encoder: IEncoder; - name: string; -} => { - const errors: string[] = []; - - for (const [name, fn] of modules) { - try { - return { - // eslint-disable-next-line @typescript-eslint/no-var-requires - ...fn(require(name)), - name - }; - } catch (e) { - errors.push(`Failed to load ${name}: ${e}`); - continue; - } - } - - throw new Error(`Could not load opus module, tried ${modules.length} different modules. Errors: ${errors.join('\n')}`); -}; - -export const CTL = { - BITRATE: 0xfa2, - FEC: 0xfac, - PLP: 0xfae -} as const; - -export const OPUS_MOD_REGISTRY: IMod[] = [ - [ - 'mediaplex', - (mod) => { - if (!mod.OpusEncoder) throw new Error('Unsupported mediaplex version'); - return { Encoder: mod.OpusEncoder }; - } - ], - ['@discordjs/opus', (opus) => ({ Encoder: opus.OpusEncoder })], - ['opusscript', (opus) => ({ Encoder: opus })], - [ - '@evan/opus', - (opus) => { - const { Encoder, Decoder } = opus as typeof import('@evan/opus'); - - class OpusEncoder { - private _encoder!: InstanceType | null; - private _decoder!: InstanceType | null; - - public constructor(private _rate: number, private _channels: number, private _application: number) {} - - private _ensureEncoder() { - if (this._encoder) return; - this._encoder = new Encoder({ - channels: this._channels as 2, - sample_rate: this._rate as 48000, - application: ({ - 2048: 'voip', - 2049: 'audio', - 2051: 'restricted_lowdelay' - })[this._application] - }); - } - - private _ensureDecoder() { - if (this._decoder) return; - this._decoder = new Decoder({ - channels: this._channels as 2, - sample_rate: this._rate as 48000 - }); - } - - public encode(buffer: Buffer) { - this._ensureEncoder(); - return Buffer.from(this._encoder!.encode(buffer)); - } - - public decode(buffer: Buffer) { - this._ensureDecoder(); - return Buffer.from(this._decoder!.decode(buffer)); - } - - public applyEncoderCTL(ctl: number, value: number) { - this._ensureEncoder(); - this._encoder!.ctl(ctl, value); - } - - public delete() { - this._encoder = null; - this._decoder = null; - } - } - - return { Encoder: OpusEncoder }; - } - ], - ['node-opus', (opus) => ({ Encoder: opus.OpusEncoder })] -]; - -let Opus: { Encoder?: IEncoder; name?: string } = {}; - -function loadOpus(refresh = false) { - if (Opus.Encoder && !refresh) return Opus; - - Opus = loadModule(OPUS_MOD_REGISTRY); - return Opus; -} - -const charCode = (x: string) => x.charCodeAt(0); -const OPUS_HEAD = Buffer.from([...'OpusHead'].map(charCode)); -const OPUS_TAGS = Buffer.from([...'OpusTags'].map(charCode)); - -export interface IOpusStreamInit { - frameSize: number; - channels: number; - rate: number; - application?: number; -} - -// frame size = (channels * rate * frame_duration) / 1000 - -/** - * Takes a stream of Opus data and outputs a stream of PCM data, or the inverse. - * **You shouldn't directly instantiate this class, see opus.Encoder and opus.Decoder instead!** - * @memberof opus - * @extends TransformStream - * @protected - */ -export class OpusStream extends Transform { - public encoder: InstanceType | null = null; - public _options: IOpusStreamInit; - public _required: number; - /** - * Creates a new Opus transformer. - * @private - * @memberof opus - * @param {Object} [options] options that you would pass to a regular Transform stream - */ - constructor(options = {} as IOpusStreamInit) { - if (!loadOpus().Encoder) { - throw Error(`Could not find an Opus module! Please install one of ${OPUS_MOD_REGISTRY.map((o) => o[0]).join(', ')}.`); - } - super(Object.assign({ readableObjectMode: true }, options)); - - const lib = Opus as Required; - - if (lib.name === 'opusscript') { - options.application = lib.Encoder.Application![options.application!]; - } - - this.encoder = new lib.Encoder(options.rate, options.channels, options.application!); - - this._options = options; - this._required = this._options.frameSize * this._options.channels * 2; - } - - _encode(buffer: Buffer) { - if (Opus.name === 'opusscript') { - return this.encoder!.encode(buffer, this._options.frameSize); - } else { - return this.encoder!.encode(buffer); - } - } - - _decode(buffer: Buffer) { - if (Opus.name === 'opusscript') { - return this.encoder!.decode(buffer, this._options.frameSize); - } else { - return this.encoder!.decode(buffer); - } - } - - /** - * Returns the Opus module being used - `mediaplex`, `opusscript`, `node-opus`, or `@discordjs/opus`. - * @type {string} - * @readonly - * @example - * console.log(`Using Opus module ${prism.opus.Encoder.type}`); - */ - static get type() { - return Opus.name; - } - - /** - * Sets the bitrate of the stream. - * @param {number} bitrate the bitrate to use use, e.g. 48000 - * @public - */ - setBitrate(bitrate: number) { - (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.BITRATE, Math.min(128e3, Math.max(16e3, bitrate))]); - } - - /** - * Enables or disables forward error correction. - * @param {boolean} enabled whether or not to enable FEC. - * @public - */ - setFEC(enabled: boolean) { - (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.FEC, enabled ? 1 : 0]); - } - - /** - * Sets the expected packet loss over network transmission. - * @param {number} [percentage] a percentage (represented between 0 and 1) - */ - setPLP(percentage: number) { - (this.encoder!.applyEncoderCTL! || this.encoder!.encoderCTL).apply(this.encoder!, [CTL.PLP, Math.min(100, Math.max(0, percentage * 100))]); - } - - _final(cb: () => void) { - this._cleanup(); - cb(); - } - - _destroy(err: Error | null, cb: (err: Error | null) => void) { - this._cleanup(); - return cb ? cb(err) : undefined; - } - - /** - * Cleans up the Opus stream when it is no longer needed - * @private - */ - _cleanup() { - if (typeof this.encoder?.delete === 'function') this.encoder!.delete!(); - this.encoder = null; - } -} - -/** - * An Opus encoder stream. - * - * Outputs opus packets in [object mode.](https://nodejs.org/api/stream.html#stream_object_mode) - * @extends opus.OpusStream - * @memberof opus - * @example - * const encoder = new prism.opus.Encoder({ frameSize: 960, channels: 2, rate: 48000 }); - * pcmAudio.pipe(encoder); - * // encoder will now output Opus-encoded audio packets - */ -export class OpusEncoder extends OpusStream { - _buffer: Buffer = Buffer.allocUnsafe(0); - - /** - * Creates a new Opus encoder stream. - * @memberof opus - * @param {Object} options options that you would pass to a regular OpusStream, plus a few more: - * @param {number} options.frameSize the frame size in bytes to use (e.g. 960 for stereo audio at 48KHz with a frame - * duration of 20ms) - * @param {number} options.channels the number of channels to use - * @param {number} options.rate the sampling rate in Hz - */ - constructor(options = {} as IOpusStreamInit) { - super(options); - } - - public _transform(newChunk: Buffer, encoding: BufferEncoding, done: TransformCallback): void { - const chunk = Buffer.concat([this._buffer, newChunk]); - - let i = 0; - while (chunk.length >= i + this._required) { - const pcm = chunk.slice(i, i + this._required); - let opus: Buffer | undefined; - try { - opus = this.encoder!.encode(pcm); - } catch (error) { - done(error as Error); - return; - } - this.push(opus); - i += this._required; - } - - if (i > 0) this._buffer = chunk.slice(i); - done(); - } - - _destroy(err: Error, cb: (err: Error | null) => void) { - super._destroy(err, cb); - this._buffer = Buffer.allocUnsafe(0); - } -} - -/** - * An Opus decoder stream. - * - * Note that any stream you pipe into this must be in - * [object mode](https://nodejs.org/api/stream.html#stream_object_mode) and should output Opus packets. - * @extends opus.OpusStream - * @memberof opus - * @example - * const decoder = new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }); - * input.pipe(decoder); - * // decoder will now output PCM audio - */ -export class OpusDecoder extends OpusStream { - _transform(chunk: Buffer, encoding: BufferEncoding, done: (e?: Error | null, chunk?: Buffer) => void) { - const signature = chunk.slice(0, 8); - if (chunk.length >= 8 && signature.equals(OPUS_HEAD)) { - this.emit('format', { - channels: this._options.channels, - sampleRate: this._options.rate, - bitDepth: 16, - float: false, - signed: true, - version: chunk.readUInt8(8), - preSkip: chunk.readUInt16LE(10), - gain: chunk.readUInt16LE(16) - }); - return done(); - } - if (chunk.length >= 8 && signature.equals(OPUS_TAGS)) { - this.emit('tags', chunk); - return done(); - } - try { - this.push(this._decode(chunk)); - } catch (e) { - return done(e as Error); - } - return done(); - } -} +export * from './OggDemuxer'; +export * from './OpusEncoder'; +export * from './WebmDemuxer'; // eslint-disable-next-line @typescript-eslint/no-inferrable-types export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 6fa7319139..ca485c74a6 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,11 +1,14 @@ { "name": "@discord-player/tsconfig", - "version": "0.0.1", + "version": "7.0.0-dev.0", "description": "TSConfig for discord-player monorepo", "author": "twlite", "homepage": "https://discord-player.js.org", "license": "MIT", "main": "index.js", + "publishConfig": { + "access": "public" + }, "files": [ "index.js", "base.json" diff --git a/packages/utils/package.json b/packages/utils/package.json index 615cee163a..c980662845 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@discord-player/utils", - "version": "0.2.2", + "version": "7.0.0-dev.0", "description": "Discord Player Utilities", "keywords": [ "discord-player" @@ -11,6 +11,9 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "module": "dist/index.mjs", + "publishConfig": { + "access": "public" + }, "directories": { "src": "src" }, diff --git a/packages/voice/LICENSE b/packages/voice/LICENSE deleted file mode 100644 index fe07fc7364..0000000000 --- a/packages/voice/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020 Androz2091 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/voice/README.md b/packages/voice/README.md deleted file mode 100644 index 99c3658124..0000000000 --- a/packages/voice/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# `@discord-player/voice` - -A high level framework for Discord VoIP client. - -> WIP - -## Installation - -```sh -$ yarn add @discord-player/voice -``` - -## Example - -```js -import pkg from '@discord-player/voice'; - -// other code -``` diff --git a/packages/voice/__test__/sum.spec.ts b/packages/voice/__test__/sum.spec.ts deleted file mode 100644 index 6d3cd1af62..0000000000 --- a/packages/voice/__test__/sum.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { add } from '../src'; -import { describe, it, expect } from 'vitest'; - -describe('Sum', () => { - it('should add two numbers', () => { - expect(add(2, 2)).toBe(4); - }); -}); diff --git a/packages/voice/package.json b/packages/voice/package.json deleted file mode 100644 index e5e8c8c13c..0000000000 --- a/packages/voice/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@discord-player/voice", - "version": "0.1.0", - "description": "A high level framework for Discord VoIP client", - "keywords": [ - "discord-player", - "music", - "bot", - "discord.js", - "javascript", - "voip", - "lavalink", - "lavaplayer" - ], - "author": "Androz2091 ", - "homepage": "https://discord-player.js.org", - "license": "MIT", - "main": "dist/index.js", - "module": "dist/index.mjs", - "types": "dist/index.d.ts", - "files": [ - "dist" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/Androz2091/discord-player.git" - }, - "scripts": { - "build": "tsup", - "build:check": "tsc --noEmit", - "lint": "eslint src --ext .ts --fix", - "test": "vitest", - "coverage": "vitest run --coverage" - }, - "bugs": { - "url": "https://github.com/Androz2091/discord-player/issues" - }, - "devDependencies": { - "@discord-player/tsconfig": "workspace:^", - "tsup": "^7.2.0", - "typescript": "^5.2.2", - "vitest": "^0.34.6" - }, - "dependencies": { - "@discord-player/utils": "workspace:^", - "discord-voip": "^0.1.2" - } -} diff --git a/packages/voice/src/DiscordVoiceAdapter.ts b/packages/voice/src/DiscordVoiceAdapter.ts deleted file mode 100644 index c4609012d9..0000000000 --- a/packages/voice/src/DiscordVoiceAdapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DiscordGatewayAdapterLibraryMethods } from 'discord-voip'; -import { GatewayVoiceServerUpdateDispatch, GatewayVoiceStateUpdateDispatch } from 'discord-api-types/v10'; -import { VoiceManager } from './VoiceManager'; - -export type VoiceAdapterData = DiscordGatewayAdapterLibraryMethods & { - id: string; -}; - -export type VoiceAdapterIncomingPayload = GatewayVoiceServerUpdateDispatch | GatewayVoiceStateUpdateDispatch; - -export class DiscordVoiceAdapter { - public constructor(public readonly manager: VoiceManager, public methods: VoiceAdapterData) {} - - public onPayload(data: VoiceAdapterIncomingPayload) { - void data; - } - - public sendPayload(data: unknown) { - // return this.manager.emit('payload', data); - void data; - } - - public destroy() { - this.manager.internalAdaptersCache.delete(this.methods.id); - } -} diff --git a/packages/voice/src/VoiceConnection.ts b/packages/voice/src/VoiceConnection.ts deleted file mode 100644 index ba20a6f884..0000000000 --- a/packages/voice/src/VoiceConnection.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DiscordVoiceConnection } from './common'; -import type { VoiceJoinConfig, VoiceManager } from './VoiceManager'; - -export class VoiceConnection { - public constructor(public readonly manager: VoiceManager, public readonly connection: DiscordVoiceConnection) {} - - public get channel() { - return this.connection.joinConfig.channelId; - } - - public get guild() { - return this.connection.joinConfig.guildId; - } - - public static async create(manager: VoiceManager, config: VoiceJoinConfig) { - void config; - // const connection = await DiscordJoinVoiceChannel({ - // ...config, - // adapterCreator: (methods) => { - // manager.adapter; - // } - // }); - - // return new VoiceConnection(manager, connection); - } -} diff --git a/packages/voice/src/VoiceManager.ts b/packages/voice/src/VoiceManager.ts deleted file mode 100644 index fd125ad9a8..0000000000 --- a/packages/voice/src/VoiceManager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Collection } from '@discord-player/utils'; -import { DiscordGatewayAdapterImplementerMethods } from 'discord-voip'; -// import { DiscordVoiceAdapter } from './DiscordVoiceAdapter'; -import { VoiceConnection } from './VoiceConnection'; - -export interface VoiceJoinConfig { - channelId: string; - guildId: string; - group?: string; - selfDeaf?: boolean; - selfMute?: boolean; -} - -export class VoiceManager { - public connections = new Collection(); - // public adapter = new DiscordVoiceAdapter(this); - public internalAdaptersCache = new Collection(); - - public async join(config: VoiceJoinConfig) { - if (this.connections.has(config.channelId)) return this.connections.get(config.channelId)!; - const connection = await VoiceConnection.create(this, config); - // this.connections.set(connection.channel!, connection); - - return connection; - } -} diff --git a/packages/voice/src/common.ts b/packages/voice/src/common.ts deleted file mode 100644 index 05cdf561a3..0000000000 --- a/packages/voice/src/common.ts +++ /dev/null @@ -1 +0,0 @@ -export { AudioPlayer as DiscordAudioPlayer, VoiceConnection as DiscordVoiceConnection, joinVoiceChannel as DiscordJoinVoiceChannel } from 'discord-voip'; diff --git a/packages/voice/src/index.ts b/packages/voice/src/index.ts deleted file mode 100644 index ac4ac60b7c..0000000000 --- a/packages/voice/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const add = (a: number, b: number) => { - return a + b; -}; - -// eslint-disable-next-line @typescript-eslint/no-inferrable-types -export const version: string = '[VI]{{inject}}[/VI]'; diff --git a/packages/voice/tsup.config.ts b/packages/voice/tsup.config.ts deleted file mode 100644 index e161ba4d4d..0000000000 --- a/packages/voice/tsup.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from '../../tsup.config'; -import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector'; - -export default defineConfig({ - esbuildPlugins: [esbuildPluginVersionInjector()] -}); diff --git a/packages/voice/vitest.config.ts b/packages/voice/vitest.config.ts deleted file mode 100644 index e055dbaa74..0000000000 --- a/packages/voice/vitest.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - dir: `${__dirname}/__test__`, - passWithNoTests: true, - watch: false - } -}); diff --git a/scripts/publish.mjs b/scripts/publish.mjs new file mode 100644 index 0000000000..77aa051bac --- /dev/null +++ b/scripts/publish.mjs @@ -0,0 +1,27 @@ +/* eslint-disable */ + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process' + +const otherFlags = process.argv.slice(2); +const FILE_NAME = 'package.json'; +const ENTRYPOINT = join(process.cwd(), 'packages'); + +const packages = await readdir(ENTRYPOINT); + +for (const dir of packages) { + const path = join(ENTRYPOINT, dir, FILE_NAME); + const packageJson = JSON.parse(await readFile(path, 'utf8')); + + const name = packageJson.name; + const tag = packageJson.version.split('-')[1]?.split('.')[0]; + + const cmd = `yarn workspace ${name} npm publish --access public${tag ? ` --tag ${tag}` : ''}${otherFlags.length ? ` ${otherFlags.join(' ')}` : ''}`; + + console.log(`\nRunning: ${cmd}\n`); + + execSync(cmd, { + stdio: 'inherit' + }); +} \ No newline at end of file diff --git a/scripts/set-version.mjs b/scripts/set-version.mjs new file mode 100644 index 0000000000..f95d8e0310 --- /dev/null +++ b/scripts/set-version.mjs @@ -0,0 +1,44 @@ +/* eslint-disable */ + +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const VERSION_PATTERN = /^\d+\.\d+\.\d+(-\S+\.\d+)?/; +const version = process.argv[2]; + +if (!version) { + console.error('Usage: command '); + process.exit(1); +} + +if (!VERSION_PATTERN.test(version)) { + console.error('Invalid version format. Use semver format: .. or ..-.'); + process.exit(1); +} + +const FILE_NAME = 'package.json'; +const ENTRYPOINT = join(process.cwd(), 'packages'); + +const packages = await readdir(ENTRYPOINT); + +for (const dir of packages) { + try { + const path = join(ENTRYPOINT, dir, FILE_NAME); + const packageJson = JSON.parse(await readFile(path, 'utf8')); + + const oldVersion = packageJson.version; + + if (oldVersion === version) { + console.log(`Version in ${dir} is already up to date.`); + continue; + } + + packageJson.version = version; + + console.log(`Updating version in ${dir} from ${oldVersion} to ${version}`); + + await writeFile(path, JSON.stringify(packageJson, null, 2)); + } catch (e) { + console.error(`Error updating version in ${dir}: ${e.message}`); + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 6363bd642b..3c19f6c9f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,42 +82,6 @@ __metadata: languageName: node linkType: hard -"@discord-player/adapter-local@workspace:packages/adapter-local": - version: 0.0.0-use.local - resolution: "@discord-player/adapter-local@workspace:packages/adapter-local" - dependencies: - "@discord-player/tsconfig": "workspace:^" - tsup: "npm:^7.2.0" - typescript: "npm:^5.2.2" - vitest: "npm:^0.34.6" - languageName: unknown - linkType: soft - -"@discord-player/adapter-remote@workspace:packages/adapter-remote": - version: 0.0.0-use.local - resolution: "@discord-player/adapter-remote@workspace:packages/adapter-remote" - dependencies: - "@discord-player/tsconfig": "workspace:^" - tsup: "npm:^7.2.0" - typescript: "npm:^5.2.2" - vitest: "npm:^0.34.6" - languageName: unknown - linkType: soft - -"@discord-player/core@workspace:packages/core": - version: 0.0.0-use.local - resolution: "@discord-player/core@workspace:packages/core" - dependencies: - "@discord-player/tsconfig": "workspace:^" - "@discord-player/utils": "workspace:^" - discord-api-types: "npm:^0.37.2" - discord-voip: "npm:^0.1.2" - tsup: "npm:^7.2.0" - typescript: "npm:^5.2.2" - vitest: "npm:^0.34.6" - languageName: unknown - linkType: soft - "@discord-player/discord-player@workspace:.": version: 0.0.0-use.local resolution: "@discord-player/discord-player@workspace:." @@ -138,18 +102,6 @@ __metadata: languageName: unknown linkType: soft -"@discord-player/downloader@workspace:packages/downloader": - version: 0.0.0-use.local - resolution: "@discord-player/downloader@workspace:packages/downloader" - dependencies: - "@discord-player/tsconfig": "workspace:^" - tsup: "npm:^7.2.0" - typescript: "npm:^5.2.2" - vitest: "npm:^0.34.6" - youtube-dl-exec: "npm:^2.1.11" - languageName: unknown - linkType: soft - "@discord-player/equalizer@workspace:^, @discord-player/equalizer@workspace:packages/equalizer": version: 0.0.0-use.local resolution: "@discord-player/equalizer@workspace:packages/equalizer" @@ -188,7 +140,7 @@ __metadata: languageName: unknown linkType: soft -"@discord-player/ffmpeg@npm:^0.1.0, @discord-player/ffmpeg@workspace:^, @discord-player/ffmpeg@workspace:packages/ffmpeg": +"@discord-player/ffmpeg@workspace:^, @discord-player/ffmpeg@workspace:packages/ffmpeg": version: 0.0.0-use.local resolution: "@discord-player/ffmpeg@workspace:packages/ffmpeg" dependencies: @@ -200,7 +152,7 @@ __metadata: languageName: unknown linkType: soft -"@discord-player/opus@npm:^0.1.0, @discord-player/opus@npm:^0.1.2, @discord-player/opus@workspace:packages/opus": +"@discord-player/opus@workspace:^, @discord-player/opus@workspace:packages/opus": version: 0.0.0-use.local resolution: "@discord-player/opus@workspace:packages/opus" dependencies: @@ -232,19 +184,6 @@ __metadata: languageName: unknown linkType: soft -"@discord-player/voice@workspace:packages/voice": - version: 0.0.0-use.local - resolution: "@discord-player/voice@workspace:packages/voice" - dependencies: - "@discord-player/tsconfig": "workspace:^" - "@discord-player/utils": "workspace:^" - discord-voip: "npm:^0.1.2" - tsup: "npm:^7.2.0" - typescript: "npm:^5.2.2" - vitest: "npm:^0.34.6" - languageName: unknown - linkType: soft - "@discordjs/builders@npm:^1.6.3": version: 1.6.3 resolution: "@discordjs/builders@npm:1.6.3" @@ -517,6 +456,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/aix-ppc64@npm:0.23.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-arm64@npm:0.17.19" @@ -545,6 +491,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm64@npm:0.23.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-arm@npm:0.17.19" @@ -573,6 +526,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-arm@npm:0.23.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/android-x64@npm:0.17.19" @@ -601,6 +561,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/android-x64@npm:0.23.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/darwin-arm64@npm:0.17.19" @@ -629,6 +596,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-arm64@npm:0.23.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/darwin-x64@npm:0.17.19" @@ -657,6 +631,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/darwin-x64@npm:0.23.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/freebsd-arm64@npm:0.17.19" @@ -685,6 +666,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-arm64@npm:0.23.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/freebsd-x64@npm:0.17.19" @@ -713,6 +701,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/freebsd-x64@npm:0.23.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-arm64@npm:0.17.19" @@ -741,6 +736,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm64@npm:0.23.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-arm@npm:0.17.19" @@ -769,6 +771,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-arm@npm:0.23.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-ia32@npm:0.17.19" @@ -797,6 +806,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ia32@npm:0.23.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-loong64@npm:0.17.19" @@ -825,6 +841,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-loong64@npm:0.23.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-mips64el@npm:0.17.19" @@ -853,6 +876,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-mips64el@npm:0.23.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-ppc64@npm:0.17.19" @@ -881,6 +911,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-ppc64@npm:0.23.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-riscv64@npm:0.17.19" @@ -909,6 +946,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-riscv64@npm:0.23.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-s390x@npm:0.17.19" @@ -937,6 +981,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-s390x@npm:0.23.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/linux-x64@npm:0.17.19" @@ -965,6 +1016,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/linux-x64@npm:0.23.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/netbsd-x64@npm:0.17.19" @@ -993,6 +1051,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/netbsd-x64@npm:0.23.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.23.0": version: 0.23.0 resolution: "@esbuild/openbsd-arm64@npm:0.23.0" @@ -1000,6 +1065,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-arm64@npm:0.23.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/openbsd-x64@npm:0.17.19" @@ -1028,6 +1100,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/openbsd-x64@npm:0.23.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/sunos-x64@npm:0.17.19" @@ -1056,6 +1135,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/sunos-x64@npm:0.23.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-arm64@npm:0.17.19" @@ -1084,6 +1170,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-arm64@npm:0.23.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-ia32@npm:0.17.19" @@ -1112,6 +1205,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-ia32@npm:0.23.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.17.19": version: 0.17.19 resolution: "@esbuild/win32-x64@npm:0.17.19" @@ -1140,6 +1240,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.23.1": + version: 0.23.1 + resolution: "@esbuild/win32-x64@npm:0.23.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -1482,6 +1589,20 @@ __metadata: languageName: node linkType: hard +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: "npm:^5.1.2" + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: "npm:^7.0.1" + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: "npm:^8.1.0" + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: 10/e9ed5fd27c3aec1095e3a16e0c0cf148d1fee55a38665c35f7b3f86a9b5d00d042ddaabc98e8a1cb7463b9378c15f22a94eb35e99469c201453eb8375191f243 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -2080,6 +2201,13 @@ __metadata: languageName: node linkType: hard +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 10/115e8ceeec6bc69dff2048b35c0ab4f8bbee12d8bb6c1f4af758604586d802b6e669dcb02dda61d078de42c2b4ddce41b3d9e726d7daa6b4b850f4adbf7333ff + languageName: node + linkType: hard + "@pkgr/utils@npm:^2.3.1": version: 2.4.1 resolution: "@pkgr/utils@npm:2.4.1" @@ -3415,6 +3543,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.24.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-android-arm64@npm:4.1.4" @@ -3422,6 +3557,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm64@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-android-arm64@npm:4.24.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-arm64@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-darwin-arm64@npm:4.1.4" @@ -3429,6 +3571,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-arm64@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.24.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-darwin-x64@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-darwin-x64@npm:4.1.4" @@ -3436,6 +3585,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-darwin-x64@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.24.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-linux-arm-gnueabihf@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.1.4" @@ -3443,6 +3599,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm-gnueabihf@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.24.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.24.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-gnu@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.1.4" @@ -3450,6 +3620,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-gnu@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.24.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-arm64-musl@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-linux-arm64-musl@npm:4.1.4" @@ -3457,6 +3634,34 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-arm64-musl@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.24.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.24.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.24.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.24.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.1.4" @@ -3464,6 +3669,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.24.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-linux-x64-musl@npm:4.1.4" @@ -3471,6 +3683,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.24.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.1.4" @@ -3478,6 +3697,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.24.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.1.4" @@ -3485,6 +3711,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.24.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.1.4": version: 4.1.4 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.1.4" @@ -3492,6 +3725,13 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.24.0": + version: 4.24.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.24.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rushstack/eslint-patch@npm:^1.1.3": version: 1.3.2 resolution: "@rushstack/eslint-patch@npm:1.3.2" @@ -4034,6 +4274,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.6": + version: 1.0.6 + resolution: "@types/estree@npm:1.0.6" + checksum: 10/9d35d475095199c23e05b431bcdd1f6fec7380612aed068b14b2a08aa70494de8a9026765a5a91b1073f636fb0368f6d8973f518a31391d519e20c59388ed88d + languageName: node + linkType: hard + "@types/hast@npm:^2.0.0": version: 2.3.4 resolution: "@types/hast@npm:2.3.4" @@ -4812,6 +5059,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^6.1.0": + version: 6.2.1 + resolution: "ansi-styles@npm:6.2.1" + checksum: 10/70fdf883b704d17a5dfc9cde206e698c16bcd74e7f196ab821511651aee4f9f76c9514bdfa6ca3a27b5e49138b89cb222a28caf3afe4567570139577f991df32 + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -5284,6 +5538,17 @@ __metadata: languageName: node linkType: hard +"bundle-require@npm:^5.0.0": + version: 5.0.0 + resolution: "bundle-require@npm:5.0.0" + dependencies: + load-tsconfig: "npm:^0.2.3" + peerDependencies: + esbuild: ">=0.18" + checksum: 10/65909bc785819dea7aede00eea3892d9f5e2a963b89f8fe0bcc97e35803dfe4eaeabb7a80f8b12015f54a7f8ead07b44c1ba8bae8fe2f18888bd11fa982c5bba + languageName: node + linkType: hard + "busboy@npm:1.6.0, busboy@npm:^1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -5528,6 +5793,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^3.6.0": + version: 3.6.0 + resolution: "chokidar@npm:3.6.0" + dependencies: + anymatch: "npm:~3.1.2" + braces: "npm:~3.0.2" + fsevents: "npm:~2.3.2" + glob-parent: "npm:~5.1.2" + is-binary-path: "npm:~2.1.0" + is-glob: "npm:~4.0.1" + normalize-path: "npm:~3.0.0" + readdirp: "npm:~3.6.0" + dependenciesMeta: + fsevents: + optional: true + checksum: 10/c327fb07704443f8d15f7b4a7ce93b2f0bc0e6cea07ec28a7570aa22cd51fcf0379df589403976ea956c369f25aa82d84561947e227cd925902e1751371658df + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -5759,6 +6043,13 @@ __metadata: languageName: node linkType: hard +"consola@npm:^3.2.3": + version: 3.2.3 + resolution: "consola@npm:3.2.3" + checksum: 10/02972dcb048c337357a3628438e5976b8e45bcec22fdcfbe9cd17622992953c4d695d5152f141464a02deac769b1d23028e8ac87f56483838df7a6bbf8e0f5a2 + languageName: node + linkType: hard + "console-control-strings@npm:^1.0.0, console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" @@ -5837,7 +6128,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -5891,13 +6182,6 @@ __metadata: languageName: node linkType: hard -"dargs@npm:~7.0.0": - version: 7.0.0 - resolution: "dargs@npm:7.0.0" - checksum: 10/b8f1e3cba59c42e1f13a114ad4848c3fc1cf7470f633ee9e9f1043762429bc97d91ae31b826fb135eefde203a3fdb20deb0c0a0222ac29d937b8046085d668d1 - languageName: node - linkType: hard - "data-uri-to-buffer@npm:^4.0.0": version: 4.0.1 resolution: "data-uri-to-buffer@npm:4.0.1" @@ -5956,6 +6240,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.5": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/71168908b9a78227ab29d5d25fe03c5867750e31ce24bf2c44a86efc5af041758bb56569b0a3d48a9b5344c00a24a777e6f4100ed6dfd9534a42c1dde285125a + languageName: node + linkType: hard + "decode-named-character-reference@npm:^1.0.0": version: 1.0.2 resolution: "decode-named-character-reference@npm:1.0.2" @@ -6188,13 +6484,6 @@ __metadata: languageName: node linkType: hard -"discord-api-types@npm:^0.37.2": - version: 0.37.28 - resolution: "discord-api-types@npm:0.37.28" - checksum: 10/b478bc29b19234701e5c74edd8212a2673bf7aa0f1c37315e6149269099fd378fb61204cba67ce5289d6fc868929d21d3e34f98b9d3d9f3f563b363601b204aa - languageName: node - linkType: hard - "discord-api-types@npm:^0.37.35, discord-api-types@npm:^0.37.41": version: 0.37.46 resolution: "discord-api-types@npm:0.37.46" @@ -6202,13 +6491,6 @@ __metadata: languageName: node linkType: hard -"discord-api-types@npm:^0.37.50": - version: 0.37.52 - resolution: "discord-api-types@npm:0.37.52" - checksum: 10/c9212437192cbd782441bc1da7c8caf4114ada6241fd9673eda70df6f8b3003acf3fbe9c59d5be22cf9bb941ac5c3f234895be79f1476f9a1ce0096e2d52367e - languageName: node - linkType: hard - "discord-player@workspace:^, discord-player@workspace:packages/discord-player": version: 0.0.0-use.local resolution: "discord-player@workspace:packages/discord-player" @@ -6222,11 +6504,12 @@ __metadata: "@types/ws": "npm:^8.5.3" "@web-scrobbler/metadata-filter": "npm:^3.1.0" discord-api-types: "npm:^0.37.0" - discord-voip: "npm:^0.1.3" + discord-voip: "workspace:^" discord.js: "npm:^14.15.3" eris: "npm:^0.17.2" libsodium-wrappers: "npm:^0.7.13" opusscript: "npm:^0.0.8" + prism-media: "npm:^1.3.5" tsup: "npm:^7.2.0" typescript: "npm:^5.2.2" vitest: "npm:^0.34.6" @@ -6235,35 +6518,19 @@ __metadata: languageName: unknown linkType: soft -"discord-voip@npm:^0.1.2": - version: 0.1.2 - resolution: "discord-voip@npm:0.1.2" - dependencies: - "@discord-player/ffmpeg": "npm:^0.1.0" - "@discord-player/opus": "npm:^0.1.0" - "@types/ws": "npm:^8.5.5" - discord-api-types: "npm:^0.37.50" - prism-media: "npm:^1.3.5" - tslib: "npm:^2.6.1" - ws: "npm:^8.13.0" - checksum: 10/5ffba1b2d9e914e58d68831606e1b319067a6d0c4195314fdd49575a87d2a2849757597ebb020965b54bb4be43acdd8c1ae7a50cc2f5ded8a8b38696ac1d5bb7 - languageName: node - linkType: hard - -"discord-voip@npm:^0.1.3": - version: 0.1.3 - resolution: "discord-voip@npm:0.1.3" +"discord-voip@workspace:^, discord-voip@workspace:packages/discord-voip": + version: 0.0.0-use.local + resolution: "discord-voip@workspace:packages/discord-voip" dependencies: - "@discord-player/ffmpeg": "npm:^0.1.0" - "@discord-player/opus": "npm:^0.1.2" - "@types/ws": "npm:^8.5.5" - discord-api-types: "npm:^0.37.50" - prism-media: "npm:^1.3.5" - tslib: "npm:^2.6.1" - ws: "npm:^8.13.0" - checksum: 10/640db80b7ceb5be392b30d3f801fa9eaa2207204eaaa04fd265404f26d17fad4d7976de9c3d14a6c525b8bf23a3b69ac307300230e01bdd5f4689e3740961426 - languageName: node - linkType: hard + "@discord-player/ffmpeg": "workspace:^" + "@discord-player/opus": "workspace:^" + "@discord-player/tsconfig": "workspace:^" + "@discord-player/utils": "workspace:^" + "@types/ws": "npm:^8.5.10" + tsup: "npm:^8.2.4" + typescript: "npm:^5.5.4" + languageName: unknown + linkType: soft "discord.js@npm:^14.15.3": version: 14.15.3 @@ -6402,6 +6669,13 @@ __metadata: languageName: node linkType: hard +"eastasianwidth@npm:^0.2.0": + version: 0.2.0 + resolution: "eastasianwidth@npm:0.2.0" + checksum: 10/9b1d3e1baefeaf7d70799db8774149cef33b97183a6addceeba0cf6b85ba23ee2686f302f14482006df32df75d32b17c509c143a3689627929e4a8efaf483952 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -6817,6 +7091,89 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.23.0": + version: 0.23.1 + resolution: "esbuild@npm:0.23.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.23.1" + "@esbuild/android-arm": "npm:0.23.1" + "@esbuild/android-arm64": "npm:0.23.1" + "@esbuild/android-x64": "npm:0.23.1" + "@esbuild/darwin-arm64": "npm:0.23.1" + "@esbuild/darwin-x64": "npm:0.23.1" + "@esbuild/freebsd-arm64": "npm:0.23.1" + "@esbuild/freebsd-x64": "npm:0.23.1" + "@esbuild/linux-arm": "npm:0.23.1" + "@esbuild/linux-arm64": "npm:0.23.1" + "@esbuild/linux-ia32": "npm:0.23.1" + "@esbuild/linux-loong64": "npm:0.23.1" + "@esbuild/linux-mips64el": "npm:0.23.1" + "@esbuild/linux-ppc64": "npm:0.23.1" + "@esbuild/linux-riscv64": "npm:0.23.1" + "@esbuild/linux-s390x": "npm:0.23.1" + "@esbuild/linux-x64": "npm:0.23.1" + "@esbuild/netbsd-x64": "npm:0.23.1" + "@esbuild/openbsd-arm64": "npm:0.23.1" + "@esbuild/openbsd-x64": "npm:0.23.1" + "@esbuild/sunos-x64": "npm:0.23.1" + "@esbuild/win32-arm64": "npm:0.23.1" + "@esbuild/win32-ia32": "npm:0.23.1" + "@esbuild/win32-x64": "npm:0.23.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/f55fbd0bfb0f86ce67a6d2c6f6780729d536c330999ecb9f5a38d578fb9fda820acbbc67d6d1d377eed8fed50fc38f14ff9cb014f86dafab94269a7fb2177018 + languageName: node + linkType: hard + "esbuild@npm:~0.17.6": version: 0.17.19 resolution: "esbuild@npm:0.17.19" @@ -7511,7 +7868,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.0.0, execa@npm:~5.1.0": +"execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: @@ -7704,6 +8061,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.0": + version: 6.4.0 + resolution: "fdir@npm:6.4.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10/e45d7c5d349ef4a4835c788944dae7ac5de7aab159511bc3ce8bc62164d4a25cb915c6d2f400886a9ed6f9d9cf38de394b71cb73935408c90eeafa0a8f6cc377 + languageName: node + linkType: hard + "fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": version: 3.2.0 resolution: "fetch-blob@npm:3.2.0" @@ -7852,6 +8221,16 @@ __metadata: languageName: node linkType: hard +"foreground-child@npm:^3.1.0": + version: 3.3.0 + resolution: "foreground-child@npm:3.3.0" + dependencies: + cross-spawn: "npm:^7.0.0" + signal-exit: "npm:^4.0.1" + checksum: 10/e3a60480f3a09b12273ce2c5fcb9514d98dd0e528f58656a1b04680225f918d60a2f81f6a368f2f3b937fcee9cfc0cbf16f1ad9a0bc6a3a6e103a84c9a90087e + languageName: node + linkType: hard + "form-data-encoder@npm:^2.1.2": version: 2.1.4 resolution: "form-data-encoder@npm:2.1.4" @@ -8224,6 +8603,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.3.10": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10/698dfe11828b7efd0514cd11e573eaed26b2dff611f0400907281ce3eab0c1e56143ef9b35adc7c77ecc71fba74717b510c7c223d34ca8a98ec81777b293d4ac + languageName: node + linkType: hard + "glob@npm:^7.1.3": version: 7.2.0 resolution: "glob@npm:7.2.0" @@ -9420,13 +9815,6 @@ __metadata: languageName: node linkType: hard -"is-unix@npm:~2.0.1": - version: 2.0.7 - resolution: "is-unix@npm:2.0.7" - checksum: 10/64a6f0bb1e4fe2a4d152484073547c5d55e66b4b7bc9857e5074d251c8d6efbf524249e3663c79f5b98034348b8cc07a36770ebe86fece2635c9c283ab60fc59 - languageName: node - linkType: hard - "is-weakref@npm:^1.0.2": version: 1.0.2 resolution: "is-weakref@npm:1.0.2" @@ -9462,6 +9850,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^3.1.2": + version: 3.4.3 + resolution: "jackspeak@npm:3.4.3" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10/96f8786eaab98e4bf5b2a5d6d9588ea46c4d06bbc4f2eb861fdd7b6b182b16f71d8a70e79820f335d52653b16d4843b29dd9cdcf38ae80406756db9199497cf3 + languageName: node + linkType: hard + "jiti@npm:^1.18.2": version: 1.18.2 resolution: "jiti@npm:1.18.2" @@ -9471,7 +9872,7 @@ __metadata: languageName: node linkType: hard -"joycon@npm:^3.0.1": +"joycon@npm:^3.0.1, joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" checksum: 10/4b36e3479144ec196425f46b3618f8a96ce7e1b658f091a309cd4906215f5b7a402d7df331a3e0a09681381a658d0c5f039cb3cf6907e0a1e17ed847f5d37775 @@ -9692,6 +10093,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.1": + version: 3.1.2 + resolution: "lilconfig@npm:3.1.2" + checksum: 10/8058403850cfad76d6041b23db23f730e52b6c17a8c28d87b90766639ca0ee40c748a3e85c2d7bd133d572efabff166c4b015e5d25e01fd666cb4b13cfada7f0 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -9813,6 +10221,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.2.0": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 10/e6e90267360476720fa8e83cc168aa2bf0311f3f2eea20a6ba78b90a885ae72071d9db132f40fda4129c803e7dcec3a6b6a6fbb44ca90b081630b810b5d6a41a + languageName: node + linkType: hard + "lru-cache@npm:^4.0.1": version: 4.1.5 resolution: "lru-cache@npm:4.1.5" @@ -11071,6 +11486,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.4": + version: 9.0.5 + resolution: "minimatch@npm:9.0.5" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/dd6a8927b063aca6d910b119e1f2df6d2ce7d36eab91de83167dd136bb85e1ebff97b0d3de1cb08bd1f7e018ca170b4962479fefab5b2a69e2ae12cb2edc8348 + languageName: node + linkType: hard + "minimist@npm:^1.2.0, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" @@ -11154,6 +11578,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -11217,7 +11648,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -11942,6 +12373,13 @@ __metadata: languageName: node linkType: hard +"package-json-from-dist@npm:^1.0.0": + version: 1.0.1 + resolution: "package-json-from-dist@npm:1.0.1" + checksum: 10/58ee9538f2f762988433da00e26acc788036914d57c71c246bf0be1b60cdbd77dd60b6a3e1a30465f0b248aeb80079e0b34cb6050b1dfa18c06953bb1cbc7602 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -12053,6 +12491,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10/5e8845c159261adda6f09814d7725683257fcc85a18f329880ab4d7cc1d12830967eae5d5894e453f341710d5484b8fdbbd4d75181b4d6e1eb2f4dc7aeadc434 + languageName: node + linkType: hard + "path-to-regexp@npm:0.1.7": version: 0.1.7 resolution: "path-to-regexp@npm:0.1.7" @@ -12138,6 +12586,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.0.1": + version: 1.1.0 + resolution: "picocolors@npm:1.1.0" + checksum: 10/a2ad60d94d185c30f2a140b19c512547713fb89b920d32cc6cf658fa786d63a37ba7b8451872c3d9fc34883971fb6e5878e07a20b60506e0bb2554dce9169ccb + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -12145,6 +12600,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10/ce617b8da36797d09c0baacb96ca8a44460452c89362d7cb8f70ca46b4158ba8bc3606912de7c818eb4a939f7f9015cef3c766ec8a0c6bfc725fdc078e39c717 + languageName: node + linkType: hard + "pidtree@npm:^0.3.0": version: 0.3.1 resolution: "pidtree@npm:0.3.1" @@ -12253,6 +12715,29 @@ __metadata: languageName: node linkType: hard +"postcss-load-config@npm:^6.0.1": + version: 6.0.1 + resolution: "postcss-load-config@npm:6.0.1" + dependencies: + lilconfig: "npm:^3.1.1" + peerDependencies: + jiti: ">=1.21.0" + postcss: ">=8.0.9" + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + checksum: 10/1691cfc94948a9373d4f7b3b7a8500cfaf8cb2dcc2107c14f90f2a711a9892a362b0866894ac5bb723455fa685a15116d9ed3252188689c4502b137c19d6bdc4 + languageName: node + linkType: hard + "postcss-nested@npm:^6.0.1": version: 6.0.1 resolution: "postcss-nested@npm:6.0.1" @@ -13201,6 +13686,69 @@ __metadata: languageName: node linkType: hard +"rollup@npm:^4.19.0": + version: 4.24.0 + resolution: "rollup@npm:4.24.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.24.0" + "@rollup/rollup-android-arm64": "npm:4.24.0" + "@rollup/rollup-darwin-arm64": "npm:4.24.0" + "@rollup/rollup-darwin-x64": "npm:4.24.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.24.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.24.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.24.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.24.0" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.24.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.24.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.24.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.24.0" + "@rollup/rollup-linux-x64-musl": "npm:4.24.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.24.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.24.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.24.0" + "@types/estree": "npm:1.0.6" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-powerpc64le-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10/291dce8f180628a73d6749119a3e50aa917c416075302bc6f6ac655affc7f0ce9d7f025bef7318d424d0c5623dcb83e360f9ea0125273b6a2285c232172800cc + languageName: node + linkType: hard + "run-applescript@npm:^5.0.0": version: 5.0.0 resolution: "run-applescript@npm:5.0.0" @@ -13576,21 +14124,10 @@ __metadata: languageName: node linkType: hard -"simple-concat@npm:^1.0.0": - version: 1.0.1 - resolution: "simple-concat@npm:1.0.1" - checksum: 10/4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a - languageName: node - linkType: hard - -"simple-get@npm:~4.0.1": - version: 4.0.1 - resolution: "simple-get@npm:4.0.1" - dependencies: - decompress-response: "npm:^6.0.0" - once: "npm:^1.3.1" - simple-concat: "npm:^1.0.0" - checksum: 10/93f1b32319782f78f2f2234e9ce34891b7ab6b990d19d8afefaa44423f5235ce2676aae42d6743fecac6c8dfff4b808d4c24fe5265be813d04769917a9a44f36 +"signal-exit@npm:^4.0.1": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10/c9fa63bbbd7431066174a48ba2dd9986dfd930c3a8b59de9c29d7b6854ec1c12a80d15310869ea5166d413b99f041bfa3dd80a7947bcd44ea8e6eb3ffeabfa1f languageName: node linkType: hard @@ -13883,7 +14420,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -13894,6 +14431,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^5.0.1, string-width@npm:^5.1.2": + version: 5.1.2 + resolution: "string-width@npm:5.1.2" + dependencies: + eastasianwidth: "npm:^0.2.0" + emoji-regex: "npm:^9.2.2" + strip-ansi: "npm:^7.0.1" + checksum: 10/7369deaa29f21dda9a438686154b62c2c5f661f8dda60449088f9f980196f7908fc39fdd1803e3e01541970287cf5deae336798337e9319a7055af89dafa7193 + languageName: node + linkType: hard + "string.prototype.matchall@npm:^4.0.8": version: 4.0.8 resolution: "string.prototype.matchall@npm:4.0.8" @@ -13973,7 +14521,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" dependencies: @@ -14129,6 +14677,24 @@ __metadata: languageName: node linkType: hard +"sucrase@npm:^3.35.0": + version: 3.35.0 + resolution: "sucrase@npm:3.35.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.2" + commander: "npm:^4.0.0" + glob: "npm:^10.3.10" + lines-and-columns: "npm:^1.1.6" + mz: "npm:^2.7.0" + pirates: "npm:^4.0.1" + ts-interface-checker: "npm:^0.1.9" + bin: + sucrase: bin/sucrase + sucrase-node: bin/sucrase-node + checksum: 10/bc601558a62826f1c32287d4fdfa4f2c09fe0fec4c4d39d0e257fd9116d7d6227a18309721d4185ec84c9dc1af0d5ec0e05a42a337fbb74fc293e068549aacbe + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -14288,6 +14854,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.1": + version: 0.2.9 + resolution: "tinyglobby@npm:0.2.9" + dependencies: + fdir: "npm:^6.4.0" + picomatch: "npm:^4.0.2" + checksum: 10/4570dacefa7f7371f49e52c8e4b7c4638d2cab9ee2903e1142c3eff4cfe71d1b8ab6cc55f65590a3232c27f8c0bcec794e4c2f02a4370fc79f3f4f026b5d84e7 + languageName: node + linkType: hard + "tinypool@npm:^0.7.0": version: 0.7.0 resolution: "tinypool@npm:0.7.0" @@ -14523,13 +15099,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.6.1": - version: 2.6.1 - resolution: "tslib@npm:2.6.1" - checksum: 10/5cf1aa7ea4ca7ee9b8aa3d80eb7ee86634b307fbefcb948a831c2b13728e21e156ef7fb9edcbe21f05c08f65e4cf4480587086f31133491ba1a49c9e0b28fc75 - languageName: node - linkType: hard - "tslib@npm:^2.6.2": version: 2.6.3 resolution: "tslib@npm:2.6.3" @@ -14573,6 +15142,47 @@ __metadata: languageName: node linkType: hard +"tsup@npm:^8.2.4": + version: 8.3.0 + resolution: "tsup@npm:8.3.0" + dependencies: + bundle-require: "npm:^5.0.0" + cac: "npm:^6.7.14" + chokidar: "npm:^3.6.0" + consola: "npm:^3.2.3" + debug: "npm:^4.3.5" + esbuild: "npm:^0.23.0" + execa: "npm:^5.1.1" + joycon: "npm:^3.1.1" + picocolors: "npm:^1.0.1" + postcss-load-config: "npm:^6.0.1" + resolve-from: "npm:^5.0.0" + rollup: "npm:^4.19.0" + source-map: "npm:0.8.0-beta.0" + sucrase: "npm:^3.35.0" + tinyglobby: "npm:^0.2.1" + tree-kill: "npm:^1.2.2" + peerDependencies: + "@microsoft/api-extractor": ^7.36.0 + "@swc/core": ^1 + postcss: ^8.4.12 + typescript: ">=4.5.0" + peerDependenciesMeta: + "@microsoft/api-extractor": + optional: true + "@swc/core": + optional: true + postcss: + optional: true + typescript: + optional: true + bin: + tsup: dist/cli-default.js + tsup-node: dist/cli-node.js + checksum: 10/e69571253d436c822219b4dcbde927b1c083a9b21d50aef463742c5d4bb0e69bd6ea8515a439d08ab0fb0a982126b83b7068e5d150d3c1be86d752a1b730f8f1 + languageName: node + linkType: hard + "tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -14795,6 +15405,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.5.4": + version: 5.6.2 + resolution: "typescript@npm:5.6.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/f95365d4898f357823e93d334ecda9fcade54f009b397c7d05b7621cd9e865981033cf89ccde0f3e3a7b73b1fdbae18e92bc77db237b43e912f053fef0f9a53b + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A5.1.3#optional!builtin": version: 5.1.3 resolution: "typescript@patch:typescript@npm%3A5.1.3#optional!builtin::version=5.1.3&hash=5da071" @@ -14835,6 +15455,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.5.4#optional!builtin": + version: 5.6.2 + resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin::version=5.6.2&hash=b45daf" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/060a7349adf698477b411be4ace470aee6c2c1bd99917fdf5d33697c17ec55c64fe724eb10399387530b50e9913b41528dd8bfcca0a5fc8f8bac63fbb4580a2e + languageName: node + linkType: hard + "ufo@npm:^1.3.0": version: 1.3.1 resolution: "ufo@npm:1.3.1" @@ -15681,7 +16311,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -15692,6 +16322,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" + dependencies: + ansi-styles: "npm:^6.1.0" + string-width: "npm:^5.0.1" + strip-ansi: "npm:^7.0.1" + checksum: 10/7b1e4b35e9bb2312d2ee9ee7dc95b8cb5f8b4b5a89f7dde5543fe66c1e3715663094defa50d75454ac900bd210f702d575f15f3f17fa9ec0291806d2578d1ddf + languageName: node + linkType: hard + "wrappy@npm:1": version: 1.0.2 resolution: "wrappy@npm:1.0.2" @@ -15699,21 +16340,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0": - version: 8.13.0 - resolution: "ws@npm:8.13.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10/1769532b6fdab9ff659f0b17810e7501831d34ecca23fd179ee64091dd93a51f42c59f6c7bb4c7a384b6c229aca8076fb312aa35626257c18081511ef62a161d - languageName: node - linkType: hard - "ws@npm:^8.16.0, ws@npm:^8.2.3": version: 8.18.0 resolution: "ws@npm:8.18.0" @@ -15825,18 +16451,6 @@ __metadata: languageName: node linkType: hard -"youtube-dl-exec@npm:^2.1.11": - version: 2.1.11 - resolution: "youtube-dl-exec@npm:2.1.11" - dependencies: - dargs: "npm:~7.0.0" - execa: "npm:~5.1.0" - is-unix: "npm:~2.0.1" - simple-get: "npm:~4.0.1" - checksum: 10/96584b7980679bf578a209b6d240c2c4adda9909c53e2f66a146267c71c8c51a68828ba0066087defbb4d294303a62a996bbeefd94d01c3f1a4e0fb837870a88 - languageName: node - linkType: hard - "youtube-ext@npm:^1.1.14": version: 1.1.14 resolution: "youtube-ext@npm:1.1.14"