Voice Connection Overview
In this section, you'll learn how to connect your Discord bot to voice channels using NetCord. We'll cover the basics of voice connections, managing voice state, and best practices for maintaining stable connections.
Connecting to a Voice Channel
NetCord provides a high-level and a low-level API for connecting to voice channels. The high-level API is simpler to use and is suitable for most use cases, while the low-level API gives you more control over the connection process.
You should use the high-level API unless you have specific needs that require the low-level API.
High-Level API
To connect to a voice channel using the high-level API, you can simply use the JoinVoiceChannelAsync method. This method requires the guild ID and channel ID to be specified and updates the bot's voice state to join the channel. It returns a VoiceClient instance, you can then call StartAsync on it to establish the connection.
var voiceClient = await client.JoinVoiceChannelAsync(guildId, channelId, new VoiceClientConfiguration
{
Logger = new ConsoleLogger(),
});
await voiceClient.StartAsync();
Low-Level API
The low-level API requires you to write the connection logic yourself, instead of relying on JoinVoiceChannelAsync which does it for you. You need to:
- Update the bot's voice state to join the channel using UpdateVoiceStateAsync.
- Listen for the VoiceStateUpdate event to get the voice state of the bot, which contains the session ID and the endpoint and simultaneously listen for the VoiceServerUpdate event to get the token needed to connect to the voice server.
- Create a VoiceClient instance with that data.
- Call StartAsync to establish the connection.
Here you can see how NetCord handles that in high-level API
namespace NetCord.Gateway.Voice;
public static class GatewayClientExtensions
{
/// <summary>
/// Joins a voice channel.
/// </summary>
/// <param name="client">The <see cref="GatewayClient"/> instance.</param>
/// <param name="guildId">The ID of the guild containing the channel.</param>
/// <param name="channelId">The ID of the voice channel to join.</param>
/// <param name="configuration">Configuration settings for the <see cref="VoiceClient"/>.</param>
/// <param name="timeout">The maximum amount of time to wait for the voice state and server update events. If not specified, a default timeout of 5 seconds is used.</param>
/// <param name="timeProvider">The <see cref="TimeProvider"/> to use for measuring the timeout. If not specified, <see cref="TimeProvider.System"/> is used.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <remarks>This method is not thread safe and should not be used concurrently for the same <paramref name="guildId"/>.</remarks>
/// <returns>A task, the result of which is an unconnected <see cref="VoiceClient"/> instance.</returns>
public static async Task<VoiceClient> JoinVoiceChannelAsync(this GatewayClient client,
ulong guildId,
ulong channelId,
VoiceClientConfiguration? configuration = null,
TimeSpan? timeout = null,
TimeProvider? timeProvider = null,
CancellationToken cancellationToken = default)
{
var userId = client.Id;
VoiceState? state = null;
VoiceServerUpdateEventArgs? server = null;
TaskCompletionSource eventsTaskCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
var voiceStateUpdateHandler = HandleVoiceStateUpdateAsync;
var voiceServerUpdateHandler = HandleVoiceServerUpdateAsync;
client.VoiceStateUpdate += voiceStateUpdateHandler;
client.VoiceServerUpdate += voiceServerUpdateHandler;
try
{
await client.UpdateVoiceStateAsync(new(guildId, channelId), cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch
{
client.VoiceStateUpdate -= voiceStateUpdateHandler;
client.VoiceServerUpdate -= voiceServerUpdateHandler;
throw;
}
try
{
await eventsTaskCompletionSource.Task.WaitAsync(timeout ?? new(5 * TimeSpan.TicksPerSecond),
timeProvider ?? TimeProvider.System,
cancellationToken).ConfigureAwait(false);
}
finally
{
client.VoiceStateUpdate -= voiceStateUpdateHandler;
client.VoiceServerUpdate -= voiceServerUpdateHandler;
}
if (server!.Endpoint is not { } endpoint)
throw new InvalidOperationException("The voice server is unavailable.");
return new(userId, state!.SessionId, endpoint, guildId, channelId, server.Token, configuration);
ValueTask HandleVoiceStateUpdateAsync(VoiceState arg)
{
if (arg.UserId == userId && arg.ChannelId == channelId && state is null)
{
state = arg;
if (server is not null)
eventsTaskCompletionSource.TrySetResult();
}
return default;
}
ValueTask HandleVoiceServerUpdateAsync(VoiceServerUpdateEventArgs arg)
{
if (arg.GuildId == guildId && server is null)
{
server = arg;
if (state is not null)
eventsTaskCompletionSource.TrySetResult();
}
return default;
}
}
}
Voice Connection vs Voice State
It is important to understand the difference between a voice connection and a voice state. A voice connection represents an active connection to a voice channel, allowing you to send and receive audio. A voice state, on the other hand, represents the current state of a user in relation to voice channels. Interestingly enough, a bot can have a voice state in a channel without having an active voice connection.
You can update the bot's voice state using UpdateVoiceStateAsync, you can specify the channel ID to join a channel, or set it to null to leave. The last one is useful for leaving a channel, since the VoiceClient manages the voice connection, not the voice state. If you call CloseAsync, the voice connection will be closed, but the bot will still have a voice state in the channel for some time, unless you explicitly update the voice state to leave.
Below you can see an example of leaving a voice channel while updating the voice state so that the bot doesn't appear as still being in the channel.
await voiceClient.CloseAsync();
await client.UpdateVoiceStateAsync(new(guildId, channelId: null));
Updating Our Bot
Let's use our knowledge of voice connections to update our bot so that it can join and leave voice channels. We will add two commands, /join and /leave, to handle these actions.
Below you can see the implementation of the /join command, which uses the high-level API to join a voice channel. It accepts an optional channel parameter - if provided, the bot joins that specific channel; otherwise, it joins the voice channel that the user is currently in. It also registers a disconnect handler to clean up resources when the connection is closed.
host.AddSlashCommand("join", "Joins a channel", async (ApplicationCommandContext context, IVoiceGuildChannel? channel = null) =>
{
if (context.Guild is not { } guild)
return new InteractionMessageProperties()
.WithContent("The guild is not available. Try again later.")
.WithFlags(MessageFlags.Ephemeral);
var user = context.User;
ulong channelId;
if (channel is not null)
channelId = channel.Id;
else if (guild.VoiceStates.TryGetValue(user.Id, out var voiceState))
channelId = voiceState.ChannelId.GetValueOrDefault();
else
return new InteractionMessageProperties()
.WithContent("You must specify a channel or be connected to a voice channel.")
.WithFlags(MessageFlags.Ephemeral);
var guildId = guild.Id;
if (!voiceInstances.TryAdd(guildId, null))
return new InteractionMessageProperties()
.WithContent("Already connected to a voice channel in this guild.")
.WithFlags(MessageFlags.Ephemeral);
VoiceClient? voiceClient;
try
{
voiceClient = await context.Client.JoinVoiceChannelAsync(guildId, channelId, new()
{
Logger = new ConsoleLogger(),
});
}
catch
{
voiceInstances.TryRemove(item: new(guildId, null));
await context.Client.UpdateVoiceStateAsync(new(guildId, null));
throw;
}
VoiceInstance voiceInstance = new(voiceClient);
if (!voiceInstances.TryUpdate(guildId, voiceInstance, null))
{
// This should never happen with the example code,
// but we'll handle it just in case someone modifies
// it in a way that could cause it to happen
voiceInstance.Dispose();
await context.Client.UpdateVoiceStateAsync(new(guildId, null));
return new InteractionMessageProperties()
.WithContent("Failed to register voice connection.")
.WithFlags(MessageFlags.Ephemeral);
}
voiceClient.Disconnect += args =>
{
if (args.Reconnect)
return default;
if (voiceInstances.TryRemove(item: new(guildId, voiceInstance)))
voiceInstance.Dispose();
return default;
};
try
{
await voiceClient.StartAsync();
}
catch
{
if (voiceInstances.TryRemove(item: new(guildId, voiceInstance)))
{
voiceInstance.Dispose();
await context.Client.UpdateVoiceStateAsync(new(guildId, null));
}
throw;
}
return "Joined voice channel.";
}).AddContexts(InteractionContextType.Guild);
The /leave command checks if the bot is currently connected to a voice channel in the guild. If connected, it extracts the voice connection, closes the VoiceClient using CloseAsync, disposes the voice connection instance, and updates the voice state.
host.AddSlashCommand("leave", "Leaves the voice channel", async (ApplicationCommandContext context) =>
{
if (context.Guild is not { } guild)
return new InteractionMessageProperties()
.WithContent("The guild is not available. Try again later.")
.WithFlags(MessageFlags.Ephemeral);
var guildId = guild.Id;
if (!voiceInstances.TryGetValue(guildId, out var voiceInstance) || voiceInstance is null)
return new InteractionMessageProperties()
.WithContent("Not connected to a voice channel in this guild.")
.WithFlags(MessageFlags.Ephemeral);
if (voiceInstances.TryRemove(item: new(guildId, voiceInstance)))
{
try
{
await voiceInstance.Client.CloseAsync();
}
finally
{
voiceInstance.Dispose();
await context.Client.UpdateVoiceStateAsync(new(guildId, null));
}
}
return "Left voice channel.";
}).AddContexts(InteractionContextType.Guild);
That's it! Now your bot can join and leave voice channels. You can test the commands in your Discord server to see them in action. For now, the bot doesn't do anything once it's connected, refer to Sending Voice and Receiving Voice guides to learn how to send and receive audio in voice channels.
Maintaining Voice Connections
While connecting to a voice channel is straightforward, maintaining a stable connection requires a bit of extra attention. During normal operation, your bot might get moved to another channel by a moderator, or the voice channel's region might get changed.
Discord automatically handles the voice state for your bot, but the underlying voice connection itself may need to be re-established. To detect when a reconnection is necessary, you should listen to the VoiceStateUpdate and VoiceServerUpdate events and verify the bot actually needs to re-establish the connection.
Keep in mind that these events can trigger independently. Because a single event won't provide all the necessary connection details at once, the most robust approach is to combine the fresh data from the newly triggered event with the existing data from your previous voice connection. You can then use this combined data to create a new VoiceClient instance and call StartAsync.
The newly created VoiceClient instance is completely independent of the previous one. This means it is up to you to re-apply any ongoing tasks. Any active audio operations or event listeners you had attached to the old client will not carry over automatically and must be explicitly set up again on the new instance.
See Also
- Sending Voice - Stream audio to voice channels
- Receiving Voice - Receive and record voice channel audio
- Troubleshooting - Common issues and solutions