Voice uses three connections at once. The main gateway websocket (gc) requests the join and receives the voice server details. A second websocket to the voice server coordinates the session. A UDP connection carries the audio. Audio goes over UDP because low latency matters more than guaranteed delivery. VoiceClient owns the voice websocket and the UDP connection for one channel. The main GatewayClient delivers the join events to it.
The voice gateway has its own opcodes. voice_state asks the main gateway to join a channel. voice_identify authenticates with the voice server. select_protocol picks the encryption mode. speaking marks the start and end of audio. voice_heartbeat keeps the voice websocket alive.
The voice heartbeat differs from the main gateway’s. Protocol v8 requires seq_ack, the sequence number of the last message from the server. The heartbeat must start as soon as Hello arrives, before UDP setup. Otherwise the voice server closes the websocket with code 4006.
Discord needs the bot’s external IP and port, the address the internet sees after NAT. The bot sends a 74-byte UDP request holding its SSRC. Discord returns the same packet with the external address filled in:
Field
Size
Description
Type
2 bytes
1=request, 2=response
Length
2 bytes
70 (size of remaining fields)
SSRC
4 bytes
Our audio stream identifier
Address
64 bytes
Blank in request; our external IP in response
Port
2 bytes
Blank in request; our external port in response
Discord sends no audio packets until the bot sends a Speaking event, even one with speaking=0.
A listen-only bot never sends a packet after IP discovery. NAT and conntrack mappings then expire within minutes. Inbound audio stops while the websocket stays healthy. An 8-byte counter ping every 5 seconds keeps the mapping alive. discord.js does the same. The voice server ignores unknown packets.
Op 13 (CLIENT_DISCONNECT) reports that a user left the channel. The client drops that user’s decoder and SSRC mapping. Without this, a user who rejoins inherits stale jitter state. The maps also grow for the length of the session.
The voice gateway runs two loops. _hb sends heartbeats at the interval Discord specifies and watches for ACKs. A missed ACK closes the socket with code 4000. That code preserves the session for a resume. _reconnect handles both resume and fresh reconnect. A resume keeps session_id, token, endpoint, the UDP socket, and the DAVE state. A fresh reconnect resets the audio session and re-runs _join for new server details. _listen schedules _reconnect as its own task. _reconnect therefore cancels _listen_task only when called from elsewhere. A reconnect that cancelled the task it runs in would kill itself. Failed attempts retry with exponential backoff, capped at 30 seconds. The counter resets once the client establishes or resumes a session. Recording state survives both paths. Per-user files keep accumulating across a reconnect, with a silence gap during the swap.
_listen runs the voice gateway state machine. On HELLO (op 8) it sends voice_resume if self.resuming holds and voice_identify otherwise. Op 9 confirms the resume at the gateway level. Media encryption is a separate handshake. DAVE must catch up before outgoing audio can be encrypted again. The client waits on _wait_dave_ready before setting resumed.
When the socket closes, the close code decides the recovery (docs). Codes 4003 to 4006, 4009, and 4011 mean the session’s credentials are dead. The main gateway issues voice tokens per session. The client rejoins from scratch to get fresh ones. A resume with dead credentials loops. Live testing showed that a bad resume gets 4003 Not authenticated, not 4006. Code 4014 means an admin kicked or moved the bot, or Discord is swapping the voice server. The client must not reconnect to that endpoint. It waits for gateway events instead. Any other code, such as 4015 (voice server crashed), is transient. The client resumes. Recovery runs as a separate task. The dying listener never cancels its own reconnect. The listener logs handler errors and skips them. They do not stop it.
Tear down voice ws/UDP without notifying the main gateway (used when Discord already removed us)
leave is the opposite of join: it tells the main gateway we are gone, then tears down. disconnect is the teardown alone, for when Discord has already removed us (kick, channel delete).
await vc.join(debug=True)
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
DEBUG: Received Opcode: 0
With the session up, the cell watches the UDP keepalive on the real socket. It wraps sendto without replacing it and records the 8-byte pings.
Audio arrives as RTP packets over UDP. Each packet has a 12-byte or longer RTP header, an encrypted payload, and a 4-byte nonce at the end. The header carries the sequence number, the timestamp, and the SSRC that identifies the speaker. The payload is Opus audio wrapped by DAVE, then encrypted with Discord’s transport key secret_key.
Decryption has two stages. The transport layer uses aead_xchacha20_poly1305_rtpsize. The cipher takes a 24-byte nonce. Only 4 bytes travel in the packet. The client pads them with 20 zero bytes. The plain RTP header is the AAD: authenticated, not encrypted. In rtpsize mode the AAD covers the 12-byte base header, any CSRC entries, and the 4-byte extension preamble only. The extension data itself is inside the encrypted payload. Its length is in the preamble, as a count of 32-bit words. Packets without the extension bit have no extension data. The frame that remains is DAVE-protected. self.dave.decrypt unwraps it using the user id from the SSRC map. Voice SPEAKING events fill that map. The result is the Opus payload.
A packet whose second byte is 0x78 is RTP voice data. Other values, such as 0xC9, are RTCP control packets for quality reporting. The client skips them.
An earlier version skipped a fixed 8 bytes of extension. It corrupted audio whenever the extension was not two words. On packets with no extension it discarded real Opus data. Discord offers no way to request a particular extension size. The cell below builds RTP packets by hand and runs them through the real cipher: extensions of 0, 2, and 3 words, no extension, and no DAVE session. Without DAVE the transport plaintext is the Opus frame.
fvc =object.__new__(VoiceClient)fvc.secret_key = _keycases = [dict(ext_words=0), dict(ext_words=2), dict(ext_words=3), dict(has_ext=False)]test_eq([fvc.decrypt(_mk_pkt(b'opus!', **kw), 0) for kw in cases], [b'opus!']*len(cases))
decode turns RTP voice packets into PCM and keeps each speaker’s stream in order without a jitter buffer. It compares RTP sequence numbers modulo 16 bits. It accepts wraparound from 65535 to 0. It drops duplicates and packets behind the last accepted sequence. A forward gap means packet loss. Small gaps use Opus packet-loss concealment. Larger gaps use silence.
decode decrypts and decodes a packet before its sequence and timestamp become accepted state. A corrupt packet cannot move the stream backwards or poison the next gap calculation. decode discards a reordered packet rather than buffering it. Recording stays simple at the cost of an occasional 20 ms loss.
def silence( n_smpls:int, # 2 bytes per sample (s16le)):
Recording opens one ffmpeg process per speaker and writes that speaker’s decoded PCM to its stdin. Each file covers the whole session. _written counts the samples on disk per user. Before each write the track is padded with silence up to the current wall-clock position. Audio therefore sits at the moment the user spoke it, whether the user joined late, paused, or left and rejoined. Padding happens only when a track is more than 200 ms behind. While someone is speaking, each packet appends its 20 ms and the track keeps pace. Arrival jitter never inserts silence mid-sentence. A packet whose SSRC has had no Speaking event yet has no owner, and is dropped rather than recorded under the SSRC.
start_recording drains the UDP queue first. Stale packets from before the recording never reach the new files.
stop_recording pads every track with silence out to the stop time. All files are then session length and aligned. It closes each ffmpeg stdin and waits for the process. That finalizes the containers. Without it the files can look valid but lack the buffered audio at the end.
The mix can be slow. mix_recording is separate from stop_recording to let a bot leave the channel first. Call stop_recording(mix=False), then leave(), then mix_recording(). Output options pass through to ffmpeg, for example ar=16000, ac=1 for speech-to-text.
vc.start_recording()
'/tmp/recording.mp3'
out =await vc.stop_recording()out
{'speakers': {}, 'mixed': None}
Outgoing audio is the receive path in reverse. The client encodes PCM to Opus. It wraps the frame with DAVE when the E2EE session is ready. It encrypts the frame with Discord’s transport key and sends it as an RTP packet. A bot alone in a channel negotiates a DAVE protocol version but has no MLS group until the epoch starts. Until then clients send plain Opus. davey raises NotReady if asked to encrypt earlier. The same applies briefly during epoch transitions.
The RTP sequence, timestamp, and nonce advance by hand for each 20 ms frame. If they drift or repeat, Discord hears broken audio or nothing.
DAVE-wrapped frames end with the marker 0xFAFA. Whether the encoder output carries it must agree with dave.ready. A bot alone in the channel has a negotiated version but no epoch. It sends plain Opus.
The bot must tell Discord when it starts and stops speaking. When a transmission ends, the docs require five frames of Opus silence (0xF8 0xFF 0xFE). Without them receivers interpolate the tail of the audio into the next utterance. The result is an audible garbled blip at the end of playback. Audio that arrives in chunks, such as a realtime TTS feed, should pass end=False for all but the last chunk. The transmission then stays continuous. The finally clears the speaking flag if playback fails halfway through.
Wrapping send_pkt records each packet while it still goes out. Every send_pcm ends with exactly five extra packets, the silence frames. With end=False the transmission stays open and has no tail.
The cell above called _reconnect directly. A real drop arrives as ConnectionClosed in the listener. The listener classifies the close code (docs) and schedules recovery as a separate task. A listener that ran the reconnect inline could cancel its own recovery. That bug existed once. Closing the live socket with a transient code exercises the resume path end to end.
Close codes 4003 to 4006, 4009, and 4011 mean the session’s credentials are dead. Resuming with them loops forever. The client must rejoin through the main gateway. The main gateway issues fresh credentials on rejoin. Resuming with a corrupted token produces this for real. Discord answers 4003 Not authenticated. A fake test would have guessed 4006 and encoded the wrong policy. The client should come back with a fresh token and a working session with no help.
Discord never sends a bot its own audio back. A solo send gets only RTCP in return. The receive test therefore uses a second bot in the channel, DISCORD_BOT_TOKEN_B (see .env.example).
The second bot leaves before the disconnect scenarios below, which concern this bot’s own connection.
await vcb.leave()await gcb.stop()
DEBUG: Received Opcode: 0
The last case is an admin disconnecting the bot. Discord sends the bot’s own VOICE_STATE_UPDATE with a null channel and closes the voice socket with 4014. The client must tear down, finalize any recording in progress, and not rejoin. The cell does this to the bot over REST.