Voice
Overview
Voice has two related but separate parts in this repository. bot.JoinVoiceChannel changes the bot's main-Gateway voice state. The independent voice.Client consumes the resulting voice session data, performs the voice WebSocket and UDP handshake, and accepts encoded Opus frames. This guide provides a complete runnable control-plane example and explains the transport boundary without pretending that a generic application has an audio source or WebSocket adapter.
Prerequisites
- Go
1.26.4or newer. DISCORD_TOKENset to a bot token.VOICE_GUILD_IDandVOICE_CHANNEL_IDset to numeric Discord snowflake IDs.- The bot installed in the guild and allowed to View Channel and Connect to the voice channel.
GuildsandGuildVoiceStatesenabled in the Portal and selected by the bot.- For audio playback, an application-level Opus encoder/source and a voice WebSocket connection adapter.
Architecture
The main Gateway sends a voice state update when JoinVoiceChannel is called. Discord returns a VOICE_STATE_UPDATE containing the bot's session ID and a VOICE_SERVER_UPDATE containing the voice token and endpoint. The application combines those values with guild, channel, and user IDs to configure voice.NewClient. That client performs UDP discovery, encryption, DAVE session handling, heartbeats, and Opus transport. Leaving the channel and disconnecting the voice client are separate cleanup operations.
Quick Start
The complete program below joins a channel and logs the two control-plane payloads:
export DISCORD_TOKEN='replace-with-a-bot-token'
export VOICE_GUILD_ID='123456789012345678'
export VOICE_CHANNEL_ID='234567890123456789'
go run ./path/to/your/voice-exampleUse a real voice-channel ID in a test guild. The program does not send audio.
Complete Runnable Example
This complete package main program uses current repository APIs, imports, and main. It is intentionally a control-plane example: it can join and receive the required session payloads without making up an audio source.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/discord-go/discord.go/bot"
"github.com/discord-go/discord.go/intents"
"github.com/discord-go/discord.go/snowflake"
"github.com/discord-go/discord.go/voice"
)
type voiceStatePayload struct {
GuildID snowflake.ID `json:"guild_id,string"`
ChannelID *snowflake.ID `json:"channel_id,string"`
UserID snowflake.ID `json:"user_id,string"`
SessionID string `json:"session_id"`
}
func requiredID(name string) snowflake.ID {
value := os.Getenv(name)
id, err := snowflake.Parse(value)
if err != nil || id == 0 {
log.Fatalf("%s must be a valid snowflake", name)
}
return id
}
func main() {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
log.Fatal("DISCORD_TOKEN is required")
}
guildID := requiredID("VOICE_GUILD_ID")
channelID := requiredID("VOICE_CHANNEL_ID")
runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
b := bot.New(token, bot.WithIntents(intents.Guilds|intents.GuildVoiceStates))
b.OnReady(func(ctx *bot.ReadyContext) {
log.Printf("ready as %s; joining voice channel", ctx.User.Username)
if err := ctx.Bot.JoinVoiceChannel(guildID, channelID, false, false); err != nil {
log.Printf("join voice channel: %v", err)
}
})
b.OnVoiceStateUpdate(func(event *bot.EventContext) {
var state voiceStatePayload
if err := event.Decode(&state); err != nil {
log.Printf("decode voice state: %v", err)
return
}
if state.UserID == b.AppID() {
log.Printf("voice state: guild=%s channel=%v session=%s", state.GuildID.String(), state.ChannelID, state.SessionID)
}
})
b.OnVoiceServerUpdate(func(event *bot.EventContext) {
var update voice.VoiceServerUpdate
if err := event.Decode(&update); err != nil {
log.Printf("decode voice server: %v", err)
return
}
log.Printf("voice server: guild=%s endpoint=%v", update.GuildID.String(), update.Endpoint)
})
b.OnDisconnect(func() {
log.Println("main Gateway disconnected; voice transport must also be stopped by the application")
})
if err := b.Start(runCtx); err != nil {
log.Fatal(err)
}
<-runCtx.Done()
if err := b.LeaveVoiceChannel(guildID); err != nil {
log.Printf("leave voice channel: %v", err)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := b.Stop(shutdownCtx); err != nil {
log.Printf("stop bot: %v", err)
}
}Explanation
The bot facade does not itself create a voice.Client. It only asks Discord to move the bot into the channel. A full voice implementation must wait for both the bot's voice state session ID and the server update token/endpoint, open a voice.Connection, construct voice.NewClient, set its SessionID, Token, and Endpoint, then call Connect with a deadline. The transport must call Disconnect and the bot must call LeaveVoiceChannel during cleanup.
voice.Client.SendOpus expects already encoded Opus data. PCM decoding, media playback, queueing, backpressure, and source cancellation belong to the application or a media service integration.
Basic Usage
- Select
intents.Guilds|intents.GuildVoiceStates. - Call
JoinVoiceChannel(guildID, channelID, selfMute, selfDeaf)after the bot is running. - Subscribe to
OnVoiceStateUpdateandOnVoiceServerUpdate. - Decode
voice.VoiceServerUpdatewithEventContext.Decode. - Call
LeaveVoiceChannel(guildID)when the bot should leave.
Intermediate Usage
- Correlate updates by guild ID and ignore updates for other guilds.
- Store the latest session ID, token, and endpoint under a guild-scoped lock.
- Reconnect when Discord supplies a new endpoint, using
voice.Client.Reconnectand a connection factory. - Set
voice.Client.OnAudioPacketwhen receiving and processing voice audio. - Use
context.WithTimeoutforvoice.Client.Connectand retry only after a clean failure.
Advanced Usage
- Implement
voice.Connectionover the WebSocket library used by the application and ensure writes are serialized. - Preserve DAVE session state and support the encryption mode negotiated by the current voice server.
- Run audio production through a bounded queue and stop it when
voice.Client.GetState()becomes idle. - Do not assume a voice endpoint remains valid across reconnects; update session data and close stale UDP sockets.
- Track heartbeats, UDP discovery, encryption failures, Opus send errors, and disconnect reasons.
Common Patterns
- Treat the main-Gateway voice state and the voice WebSocket as two lifecycles.
- Use a per-guild voice-session object to correlate state and server updates.
- Join only after READY and leave before process shutdown completes.
- Use
voice.Client.SetSpeakingaround playback state changes. - Increment RTP timestamps through
SendOpus; do not hand-craft packets unless the application owns the complete transport.
Best Practices
- Never log voice tokens or full session credentials.
- Bound connection and UDP discovery timeouts.
- Close the voice client before closing application-owned audio sources.
- Stop playback producers before calling
Disconnectto prevent writes to a closed UDP connection. - Handle channel deletion, guild removal, Gateway reconnect, and endpoint changes as normal lifecycle events.
- Request Connect, Speak, and other voice permissions explicitly;
GuildVoiceStatesonly controls event delivery.
Common Mistakes with wrong/correct examples
Wrong
client.JoinVoiceChannel(guildID, channelID, false, false)
voiceClient.SendOpus(pcmBytes)Correct
if err := b.JoinVoiceChannel(guildID, channelID, false, false); err != nil {
return err
}
// Wait for VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE.
// Encode PCM to Opus in the application, then call voiceClient.SendOpus(opus).Wrong
_ = voiceClient.Connect(context.Background())Correct
connectCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := voiceClient.Connect(connectCtx); err != nil {
return err
}Wrong
// Leave the main Gateway voice state and forget the voice client.
_ = b.LeaveVoiceChannel(guildID)Correct
if voiceClient != nil && voiceClient.GetState().IsActive() {
_ = voiceClient.Disconnect()
}
_ = b.LeaveVoiceChannel(guildID)The fragments show the required ordering but are not standalone programs; the complete runnable control-plane program is above.
Expected Result
The bot connects, joins the configured voice channel, logs its voice state session ID, and logs the voice server endpoint. No audio is transmitted by the runnable example. A full media implementation additionally creates and connects voice.Client, sends encoded Opus frames, and disconnects it during shutdown.