语音连接¶

bostie 的语音引擎运行在它的 Rust 核心中:语音 WebSocket、UDP 传输、20 ms 发送时钟、解码与重采样、混音器、Opus 编解码、传输加密,以及 DAVE 端到端加密。Python 只负责让它加入、播放和接收;时序从不依赖事件循环,唯一的例外是 Python 需要保持每个 sink 的缓冲区有数据。
语音需要 Intents.GUILD_VOICE_STATES,Intents.DEFAULT 中已包含它。机器人在每个服务器中最多有一个语音连接。
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)
连接¶
await ctx.voice.connect() 会加入执行命令的用户所在的语音频道或舞台频道,并在音频可以双向流动时返回。它会在缓存中查找用户所在的频道,如果该服务器没有被缓存,则向 Discord 查询;当用户不在该服务器的任何语音频道中,或交互来自私信时,引发 NotInVoice。ctx.voice.current 是机器人在该服务器中的连接,或 None。
在处理函数之外,await bot.voice.connect(channel_id) 按 ID 加入频道。在机器人已经连接的服务器中,它会移动连接,并只应用所给出的选项。bot.voice.get(guild_id) 和 bot.voice.connections 返回打开的连接,await bot.voice.close() 会像 bot.close() 那样离开所有连接。
如果加入在 timeout 秒内没有就绪,会引发原因为 TIMEOUT 的 VoiceConnectError;对于加入已满的频道或机器人无权连接的频道,Discord 会不作回应。其他失败会引发带有原因、关闭码和 Discord 消息的 VoiceConnectError。
设置¶
Bot(voice=voice.Defaults(...)) 设置每个连接的初始值,connect() 则为单次调用接受连接设置:
voice.Defaults 字段 |
默认值 | 含义 |
|---|---|---|
self_mute、self_deaf |
False |
机器人自己的静音和拒听 |
timeout |
10.0 | 等待加入、移动或编辑的秒数 |
server_change_delay |
0.0 | 跟随 Discord 切换到新语音服务器之前等待的秒数 |
encoder |
EncoderSettings() |
Opus 编码器;参见播放音频 |
speaking_flags |
MICROPHONE |
机器人播放时宣告的 voice.SpeakingFlags |
stage |
"listen" |
在舞台上:"listen","request" 请求发言,或 "speak" |
speech_hangover |
0.2 | 静默多少秒后视为说话者停止说话,0.02 到 10 |
speech_activity |
True |
是否触发开始说话和停止说话事件 |
stall_after |
2.0 | 媒体停止流动多少秒后由 MediaStalled 报告,至少 0.02 |
sink_buffer、stall |
0.2、"hold" |
每个 sink 的默认值 |
receive_buffer、jitter、overflow、receive_stall |
2.0、0.1、"drop_oldest"、"wait" |
每个接收器的默认值 |
crossfade、history |
0.0、0 | 每个队列的默认值 |
connect() 以关键字参数接受 self_mute、self_deaf、timeout、server_change_delay、encoder、speaking_flags、stage、speech_hangover、speech_activity 和 stall_after;省略或传入 None 的保持默认值。connect_external() 接受前三个,move() 接受 timeout,edit() 接受 self_mute、self_deaf 和 timeout。无效的值会在构建 Defaults 时引发 ValueError。字符串类型的设置使用字面量类型 voice.StageRole、voice.SinkStall、voice.ReceiveStall、voice.Overflow、voice.Sample、voice.OpusApplication、voice.OpusBandwidth 和 voice.OpusSignal。ctx.voice 是一个 voice.ContextVoice,bot.voice 是一个 voice.VoiceManager。
连接对象¶
VoiceConnection |
值 |
|---|---|
guild_id、channel_id |
连接所在的位置 |
state |
一个 voice.State:CONNECTING、READY、RESUMING、RECONNECTING 或 DISCONNECTED |
members |
通话中其他用户的 ID |
speaking |
当前正在说话的用户,映射到他们开始说话时的 time.monotonic() |
ssrcs |
通话中每个 SSRC 映射到其用户 ID |
self_mute、self_deaf |
机器人自己的静音和拒听,以 Discord 实际应用的为准 |
mute、deaf、suppress |
版主是否将机器人静音或拒听,或者它在这个舞台上是否不能发言 |
latency |
一个 Latency,包含 WebSocket 和 UDP 的往返时间(秒),测得之前为 None |
dave |
通话的端到端加密;见下文 |
sinks |
打开的 sink,最早打开的在前 |
encoder、speaking_flags、speech_hangover、speech_activity、stall_after |
连接期间可以修改 |
await vc.move(channel_id)移动到该服务器的另一个语音频道或舞台频道,并在媒体在那里开始流动后返回。await vc.edit(self_mute=..., self_deaf=...)修改机器人自己的静音或拒听,未给出的那一项保持不变,并在 Discord 应用后返回。await vc.stage("request")在舞台上请求发言,"speak"开始发言(需要相应权限),"listen"回到听众席。await vc.wait_until(voice.State.READY)等待某个状态,如果连接先结束了,则引发VoiceNotConnected。vc.reconnect()断开语音 WebSocket,并在新的连接上恢复。vc.stop()关闭所有 sink,于是不再播放任何内容。await vc.disconnect()离开频道,并在连接结束后返回,最多 2 秒。
在已结束的连接上调用方法会引发 VoiceNotConnected。
连接会自我修复。断开的 WebSocket 会恢复,丢失的会话会重连,当版主移动机器人时,它会跟随到新频道并发出一个 Moved 事件。当版主断开它的连接或删除频道时,它会以原因 REMOVED 结束。当机器人在被服务器静音或在舞台上被禁止发言时播放,以及在它被拒听时接收器仍在等待,bostie 都会发出警告。
端到端加密¶
bostie 支持 DAVE,即 Discord 的语音端到端加密,无需任何设置:它会加入通话的加密组,加密它发送的内容,解密它接收的内容。vc.dave 以 voice.Dave 的形式报告其状态:
protocol_version是正在使用的 DAVE 协议版本,0 表示未使用;epoch记录加密组成员的变更次数;privacy_code是 Discord 客户端为该通话显示的代码;await vc.dave.verification_code(user_id)返回用于与另一位用户在通话之外核对的代码,以确认中间没有其他人。
每次变化都会由一个 DaveTransition 事件报告。要求 DAVE 的频道遇到不支持 DAVE 的客户端时,会以原因 DAVE_REQUIRED 关闭,DAVE 的某个步骤失败时会引发 DaveError,或以 DAVE_FAILED 结束连接。
事件¶
语音连接通过 @bot.listen 发送其事件。每个事件都是带有 guild_id 的 voice.VoiceEvent。进入 DISCONNECTED 的 StateChange 带有一个 voice.DisconnectReason:REQUESTED(调用了 disconnect())、REMOVED(版主断开了机器人的连接或删除了频道)、CALL_TERMINATED、RATE_LIMITED、TIMEOUT、SESSION_INVALID、AUTHENTICATION_FAILED、DAVE_REQUIRED、DAVE_FAILED、PROTOCOL_ERROR 或 CLOSED(机器人已关闭)。
| 事件 | 字段 | 何时发出 |
|---|---|---|
StateChange |
old、new、reason、code、detail |
连接状态发生变化 |
Moved |
old_channel_id、new_channel_id、by_us |
机器人被移动,由 move() 或版主触发 |
DaveTransition |
protocol_version、epoch、transition_id |
端到端加密发生变化 |
MediaStalled、MediaResumed |
direction(voice.Direction.SEND 或 RECEIVE)、duration |
发送或接收停止,或重新开始 |
UserJoin、UserLeave |
user_id |
有用户加入或离开通话 |
SpeakingUpdate |
user_id、ssrc、flags |
有用户宣告了其 SSRC 和说话标志 |
SpeechStart、SpeechStop |
user_id、ssrc、at,停止时还有 duration |
有用户开始或停止说话 |
RtcpPacket |
packet_type、count、ssrc、user_id、reports、data |
语音服务器发送了一个 RTCP 包;reports 中的每个 ReceptionReport 都包含某条流的 fraction_lost、lost、highest_sequence、jitter、last_sender_report 和 delay |
它们位于 bostie.voice 中。SpeechStart 和 SpeechStop 只在 speech_activity 开启时触发。
媒体停滞¶
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,
)
direction 为 SEND 的 MediaStalled 会在音频在 sink 中等待、却在 stall_after 秒内无法发出时触发,例如在重连或 DAVE 过渡期间。为 RECEIVE 时,表示数据包到达了一个打开的接收器,但在这段时间内没有一个能被解密或播放。媒体重新开始流动后会接着发出 MediaResumed,其 duration 从停滞开始时计算。stall_after 为 2 秒,除非 voice.Defaults、connect(stall_after=...) 或 vc.stall_after 另有设置。
离开空频道¶
机器人会一直留在频道中,直到断开连接。要在最后一位用户离开时离开:
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()
错误¶
语音错误都是 VoiceError:VoiceConnectError(加入在就绪之前就结束了;带有 reason、code、detail)、NotInVoice、VoiceNotConnected、AlreadyConnected、VoiceQueueFull(等待该连接的命令过多)、DaveError,以及音频相关的 SourceError、SinkClosed、BufferFull、Empty 和 ReceiverClosed,后续页面会解释它们。
外部语音引擎¶
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) 为一个在别处运行的语音引擎加入频道,并返回一个 ExternalVoice,其中带有引擎连接所需的 state(VoiceStateUpdate)和 server(VoiceServerUpdate)。之后 bostie 不会在该服务器中发送音频。async with external.updates() as updates: 会传递该服务器之后的语音服务器信息以及机器人自己的语音状态,await external.disconnect() 会离开频道。在 bostie 自己维护连接的服务器中,它会引发 AlreadyConnected;它接受 self_mute、self_deaf 和 timeout。
版本¶
voice.versions() 返回编译进当前构建的 bostie、libopus、DAVE 库的版本,以及所支持的最高 DAVE 协议版本。