Skip to content

Voice

player.voices holds one VoiceState per connected guild. A voice connection requires two Discord gateway events to establish — Discolink handles them once you forward their raw payloads.

When your bot joins a voice channel, Discord sends VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. Discolink waits for both, then makes a request to Lavalink to register the player. Forward all raw dispatches — Discolink filters for them internally:

discord.js
// 'rawWS' for eris
client.on("raw", (payload) => player.voices.handleDispatch(payload));

handleDispatch processes three event types:

  • VOICE_STATE_UPDATE — captures the bot’s session ID and channel
  • VOICE_SERVER_UPDATE — captures the endpoint and token, then completes the connection
  • READY — triggers player.init() automatically when autoInit: true
const voice = await player.voices.connect(guildId, voiceChannelId);
// With options (only applied when creating a new connection)
await player.voices.connect(guildId, voiceChannelId, {
node: "eu-west", // pin to a specific node
volume: 80, // initial volume for the queue (0–1000)
filters: { timescale: { speed: 1.1 } }, // initial filters
context: { textChannelId }, // initial queue context
});

If a connection to the same guild and channel is already in progress, connect() returns the existing promise rather than starting a new one. If the bot is already connected to that channel and the connection is live, it returns the existing VoiceState immediately.

connect() times out after 30 seconds if the gateway events don’t arrive.

  • forwardVoiceUpdate not wired up — the bot never sends the voice state update to Discord, so the gateway never sends the required events back.
  • handleDispatch not wired upVOICE_STATE_UPDATE / VOICE_SERVER_UPDATE are never forwarded to discolink, so it never receives the data it needs to complete the connection.
  • Missing GuildVoiceStates intent — Discord won’t send VOICE_STATE_UPDATE without it, which means the session ID is never captured.
  • Missing channel permissions — the bot needs Connect (and Speak to produce audio) on the target channel.
  • Voice channel is full — easy to miss: when the channel has hit its user limit, Discord does not send any voice updates, so the connection times out. The bot needs the Move Members permission to bypass the limit and receive both events.

When a new connection is established, Discolink picks the best node for the voice region. It uses VoiceRegion.getRelevantNode(), which:

  1. Runs NodeManager.relevant() to get ready nodes sorted by load metrics
  2. Sorts that list by average voice WebSocket ping for the region (lower = better)
  3. Returns the first result

Ping data is collected from queueUpdate ticks — each tick carries the voice WebSocket latency from Lavalink. The average is computed over the current statsInterval window (default 60s). Nodes with no ping data for the region yet are treated as having 0ms ping, so they’re tried first until data accumulates.

const voice = player.voices.get(guildId)!;
voice.connected; // true when states of both bot and Lavalink player report connected
voice.reconnecting; // true during a voice reconnect
voice.disconnected; // true when not connected and not reconnecting
voice.changingNode; // true while changeNode() is in progress
voice.destroyed; // true if this instance is no longer in VoiceManager
voice.channelId; // current voice channel ID
voice.regionId; // voice region extracted from the endpoint (e.g. 'us-east')
voice.ping; // voice WebSocket latency in ms, sourced from Lavalink player state
voice.node; // the Node this connection is on
voice.selfDeaf; // whether the bot has deafened itself
voice.selfMute; // whether the bot has muted itself
voice.serverDeaf; // whether the guild has deafened the bot
voice.serverMute; // whether the guild has muted the bot
voice.suppressed; // whether the bot is suppressed (stage channels)

When a voice connection is severed, Discolink emits voiceClose after having received the corresponding event from the connection’s node and checks the close code. For recoverable codes, it calls voice.connect() to reconnect. If reconnection fails, the voice connection and its queue are destroyed.

// Leave the channel but keep the VoiceState and queue alive
await player.voices.disconnect(guildId);
// Tear everything down — destroys the queue first, then the VoiceState
await player.voices.destroy(guildId, "User left the channel");

destroy() is the right call when you’re done with a guild — on channel delete, bot kick, or the last user leaving. If a queue exists for the guild, voices.destroy() delegates to queues.destroy() and vice versa.

You can also call these directly on a VoiceState instance:

const voice = player.voices.get(guildId)!;
await voice.connect(); // connect to the same channel
await voice.connect(otherChannelId); // move to a different channel
await voice.disconnect(); // disconnect from the channel
await voice.destroy("reason"); // destroy with reason
await player.voices.get(guildId)!.changeNode("eu-west");

changeNode() migrates the player to the new node:

  1. Destroys the player on the old node
  2. Creates it on the new node with the same state
  3. Includes and resumes any active track if its source is supported
  4. Emits voiceChange with the previous node and whether playback was active

If changeNode() is already in progress for this voice state, the call returns the existing promise.

With relocateQueues: true (the default), this happens automatically when a node closes or disconnects. The relocation algorithm distributes queues across available nodes proportionally by their load scores.

Full API: VoiceState