Voice connections¶

bostie's voice engine runs in its Rust core: the voice WebSocket, the UDP transport, the 20 ms send clock, decoding and resampling, the mixer, Opus encoding and decoding, transport encryption, and DAVE end-to-end encryption. Python asks it to join, play and receive; the timing never depends on the event loop, except that Python has to keep each sink's buffer filled.
Voice needs Intents.GUILD_VOICE_STATES, which Intents.DEFAULT includes. The bot has at most one voice connection per guild.
import logging
import bostie
from bostie import Context, PartialChannel, User, voice
from bostie.voice import DaveTransition, Moved, StateChange
log = logging.getLogger("my-bot")
bot = bostie.Bot(voice=voice.Defaults(timeout=15.0))
@bot.command
async def join(ctx: Context) -> None:
"""Join your voice channel."""
vc = ctx.voice.current or await ctx.voice.connect()
dave = vc.dave
await ctx.respond(
f"In <#{vc.channel_id}> with {len(vc.members)} others. "
f"End-to-end encryption: protocol {dave.protocol_version}, "
f"privacy code {dave.privacy_code}."
)
@bot.command
async def verify(ctx: Context, user: User) -> None:
"""Show the code to compare with another user in the call."""
if (vc := ctx.voice.current) is None:
await ctx.respond("I am not in a voice channel.", ephemeral=True)
return
code = await vc.dave.verification_code(user.id)
await ctx.respond(f"Compare this with {user.mention}: {code}", ephemeral=True)
@bot.command
async def move(ctx: Context, channel: PartialChannel) -> None:
"""Move the bot to another voice channel."""
if (vc := ctx.voice.current) is not None:
await vc.move(channel.id)
await vc.edit(self_mute=False)
await ctx.respond("Moved.", ephemeral=True)
@bot.command
async def leave(ctx: Context) -> None:
"""Leave the voice channel."""
if (vc := ctx.voice.current) is not None:
await vc.disconnect()
await ctx.respond("Left.", ephemeral=True)
@bot.listen
async def voice_state(event: StateChange) -> None:
log.info(
"guild %d: %s -> %s (%s)", event.guild_id, event.old, event.new, event.detail
)
@bot.listen
async def moved(event: Moved) -> None:
who = "we" if event.by_us else "a moderator"
log.info("%s moved the bot to %d", who, event.new_channel_id)
@bot.listen
async def dave(event: DaveTransition) -> None:
log.info("DAVE protocol %d, epoch %s", event.protocol_version, event.epoch)
Connecting¶
await ctx.voice.connect() joins the voice or stage channel of the user who ran the command, and returns once audio can flow both ways. It finds the user's channel in the cache, or asks Discord when the guild is not cached, and raises NotInVoice when the user is in no voice channel of the guild or the interaction came from a DM. ctx.voice.current is the bot's connection in that guild, or None.
Outside a handler, await bot.voice.connect(channel_id) joins a channel by id. In a guild where the bot is already connected, it moves the connection and applies only the options given. bot.voice.get(guild_id) and bot.voice.connections return the open connections, and await bot.voice.close() leaves them all, as bot.close() does.
A join that is not ready within timeout seconds raises VoiceConnectError with reason TIMEOUT; Discord leaves a join to a full channel, or one the bot may not connect to, unanswered. Other failures raise VoiceConnectError with the reason, the close code and Discord's message.
Settings¶
Bot(voice=voice.Defaults(...)) sets what every connection starts with, and connect() takes the connection settings for one call:
voice.Defaults field |
Default | Meaning |
|---|---|---|
self_mute, self_deaf |
False |
the bot's own mute and deafen |
timeout |
10.0 | seconds to wait for a join, a move or an edit |
server_change_delay |
0.0 | seconds to wait before following Discord to a new voice server |
encoder |
EncoderSettings() |
the Opus encoder; see Playing audio |
speaking_flags |
MICROPHONE |
the voice.SpeakingFlags the bot announces when it plays |
stage |
"listen" |
on a stage: "listen", "request" to ask to speak, or "speak" |
speech_hangover |
0.2 | seconds of silence before a speaker counts as stopped, 0.02 to 10 |
speech_activity |
True |
whether speech start and stop events fire |
stall_after |
2.0 | seconds media may stop flowing before MediaStalled reports it, at least 0.02 |
sink_buffer, stall |
0.2, "hold" |
the defaults of every sink |
receive_buffer, jitter, overflow, receive_stall |
2.0, 0.1, "drop_oldest", "wait" |
the defaults of every receiver |
crossfade, history |
0.0, 0 | the defaults of every queue |
connect() takes self_mute, self_deaf, timeout, server_change_delay, encoder, speaking_flags, stage, speech_hangover, speech_activity and stall_after as keyword arguments; one left out, or None, keeps the default. connect_external() takes the first three, move() takes timeout, and edit() takes self_mute, self_deaf and timeout. Invalid values raise ValueError when the Defaults is built. The string settings have the literal types voice.StageRole, voice.SinkStall, voice.ReceiveStall, voice.Overflow, voice.Sample, voice.OpusApplication, voice.OpusBandwidth and voice.OpusSignal. ctx.voice is a voice.ContextVoice and bot.voice a voice.VoiceManager.
The connection¶
VoiceConnection |
Value |
|---|---|
guild_id, channel_id |
where it is |
state |
a voice.State: CONNECTING, READY, RESUMING, RECONNECTING or DISCONNECTED |
members |
the ids of the other users in the call |
speaking |
the users speaking now, mapped to the time.monotonic() they started |
ssrcs |
each SSRC in the call mapped to its user id |
self_mute, self_deaf |
the bot's own mute and deafen, as Discord applied them |
mute, deaf, suppress |
whether a moderator muted or deafened the bot, or it may not speak on this stage |
latency |
a Latency with the WebSocket and UDP round trips in seconds, None until measured |
dave |
the call's end-to-end encryption; see below |
sinks |
the open sinks, oldest first |
encoder, speaking_flags, speech_hangover, speech_activity, stall_after |
settable while connected |
await vc.move(channel_id)moves to another voice or stage channel of the guild and returns once media flows there.await vc.edit(self_mute=..., self_deaf=...)changes the bot's own mute or deafen, leaving the one not given as it is, and returns once Discord applied it.await vc.stage("request")asks to speak on a stage,"speak"speaks (with the permission to),"listen"goes back to the audience.await vc.wait_until(voice.State.READY)waits for a state, and raisesVoiceNotConnectedif the connection ends first.vc.reconnect()drops the voice WebSocket and resumes on a new one.vc.stop()closes every sink, so nothing plays.await vc.disconnect()leaves the channel and returns once the connection ended, at most 2 seconds later.
Calls on a connection that ended raise VoiceNotConnected.
The connection heals itself. A dropped WebSocket resumes, a lost session reconnects, and when a moderator moves the bot, it follows to the new channel and sends a Moved event. When a moderator disconnects it or deletes the channel, it ends with reason REMOVED. bostie warns when the bot plays while server muted or suppressed on a stage, and when receivers wait while it is deafened.
End-to-end encryption¶
bostie speaks DAVE, Discord's end-to-end encryption for voice, with nothing to set up: it joins the call's encryption group, encrypts what it sends and decrypts what it receives. vc.dave reports it as a voice.Dave:
protocol_versionis the DAVE protocol in use, 0 for none;epochcounts the changes of the group's members;privacy_codeis the code Discord clients show for the call;await vc.dave.verification_code(user_id)returns the code to compare with another user out of band, to verify that nobody else is in the middle.
A DaveTransition event reports each change. A channel that requires DAVE from a client that does not offer it closes with reason DAVE_REQUIRED, and a failed DAVE step raises DaveError or ends the connection with DAVE_FAILED.
Events¶
Voice connections send their events through @bot.listen. Each is a voice.VoiceEvent with the guild_id. A StateChange into DISCONNECTED carries a voice.DisconnectReason: REQUESTED (disconnect() ran), REMOVED (a moderator disconnected the bot or deleted the channel), CALL_TERMINATED, RATE_LIMITED, TIMEOUT, SESSION_INVALID, AUTHENTICATION_FAILED, DAVE_REQUIRED, DAVE_FAILED, PROTOCOL_ERROR or CLOSED (the bot closed).
| Event | Fields | Sent when |
|---|---|---|
StateChange |
old, new, reason, code, detail |
the connection changed state |
Moved |
old_channel_id, new_channel_id, by_us |
the bot moved, by move() or by a moderator |
DaveTransition |
protocol_version, epoch, transition_id |
the end-to-end encryption changed |
MediaStalled, MediaResumed |
direction (voice.Direction.SEND or RECEIVE), duration |
sending or receiving stopped, or started again |
UserJoin, UserLeave |
user_id |
a user joined or left the call |
SpeakingUpdate |
user_id, ssrc, flags |
a user announced their SSRC and speaking flags |
SpeechStart, SpeechStop |
user_id, ssrc, at, and duration on stop |
a user started or stopped speaking |
RtcpPacket |
packet_type, count, ssrc, user_id, reports, data |
the voice server sent an RTCP packet; each ReceptionReport in reports has the fraction_lost, lost, highest_sequence, jitter, last_sender_report and delay of one stream |
They live in bostie.voice. SpeechStart and SpeechStop fire only while speech_activity is on.
Stalled media¶
import logging
import bostie
from bostie import voice
log = logging.getLogger("my-bot")
bot = bostie.Bot(voice=voice.Defaults(stall_after=1.0))
@bot.listen
async def media_flow(event: voice.MediaStalled | voice.MediaResumed) -> None:
change = "stalled" if isinstance(event, voice.MediaStalled) else "resumed"
log.warning(
"guild %d: %s %s after %.1f s",
event.guild_id,
event.direction,
change,
event.duration,
)
MediaStalled with direction SEND fires when audio waits in a sink but cannot leave for stall_after seconds, during a reconnect or a DAVE transition. With RECEIVE, packets reach an open receiver but none can be opened or played for that long. MediaResumed follows once media flows again, its duration counting from the start of the stall. stall_after is 2 seconds unless voice.Defaults, connect(stall_after=...) or vc.stall_after says otherwise.
Leaving an empty channel¶
The bot stays in a channel until it disconnects. To leave when the last user does:
import bostie
from bostie import voice
bot = bostie.Bot()
@bot.listen
async def leave_when_alone(event: voice.UserLeave) -> None:
vc = bot.voice.get(event.guild_id)
if vc is not None and not vc.members:
await vc.disconnect()
Errors¶
Voice errors are VoiceErrors: VoiceConnectError (a join ended before it was ready; reason, code, detail), NotInVoice, VoiceNotConnected, AlreadyConnected, VoiceQueueFull (too many commands waiting for the connection), DaveError, and for audio SourceError, SinkClosed, BufferFull, Empty and ReceiverClosed, which the next pages explain.
External voice engines¶
import logging
import bostie
from bostie.events import VoiceServerUpdate
log = logging.getLogger("my-bot")
bot = bostie.Bot()
async def hand_to_engine(guild_id: int, channel_id: int) -> None:
external = await bot.voice.connect_external(guild_id, channel_id)
log.info(
"session %s on %s",
external.state.voice_state.session_id,
external.server.endpoint,
)
async with external.updates() as updates:
async for update in updates:
if isinstance(update, VoiceServerUpdate):
log.info("new voice server %s", update.endpoint)
elif update.voice_state.channel_id is None:
break
await external.disconnect()
await bot.voice.connect_external(guild_id, channel_id) joins a channel for a voice engine that runs elsewhere, and returns an ExternalVoice with the state (VoiceStateUpdate) and server (VoiceServerUpdate) the engine connects with. bostie then sends no audio in that guild. async with external.updates() as updates: delivers the guild's later voice servers and the bot's own voice states, and await external.disconnect() leaves the channel. It raises AlreadyConnected in a guild where bostie runs its own connection, and takes self_mute, self_deaf and timeout.
Versions¶
voice.versions() returns the versions of bostie, libopus, the DAVE library and the highest DAVE protocol compiled into the build.