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); } }