Conexões de voz¶

O motor de voz do bostie roda no seu núcleo em Rust: o WebSocket de voz, o transporte UDP, o relógio de envio de 20 ms, a decodificação e a reamostragem, o mixer, a codificação e a decodificação Opus, a criptografia de transporte e a criptografia de ponta a ponta DAVE. O Python pede a ele que entre, reproduza e receba; o tempo nunca depende do event loop, exceto que o Python precisa manter cheio o buffer de cada sink.
A voz precisa de Intents.GUILD_VOICE_STATES, que Intents.DEFAULT inclui. O bot tem no máximo uma conexão de voz por servidor.
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)
Conectando¶
await ctx.voice.connect() entra no canal de voz ou de palco do usuário que executou o comando e retorna assim que o áudio pode fluir nos dois sentidos. Ele encontra o canal do usuário no cache, ou pergunta ao Discord quando o servidor não está em cache, e levanta NotInVoice quando o usuário não está em nenhum canal de voz do servidor ou quando a interação veio de uma mensagem direta. ctx.voice.current é a conexão do bot naquele servidor, ou None.
Fora de um handler, await bot.voice.connect(channel_id) entra em um canal pelo ID. Em um servidor onde o bot já está conectado, ele move a conexão e aplica só as opções passadas. bot.voice.get(guild_id) e bot.voice.connections retornam as conexões abertas, e await bot.voice.close() sai de todas elas, como bot.close() faz.
Uma entrada que não fica pronta em até timeout segundos levanta VoiceConnectError com o motivo TIMEOUT; o Discord deixa sem resposta uma entrada em um canal cheio, ou em um ao qual o bot não pode se conectar. As outras falhas levantam VoiceConnectError com o motivo, o código de fechamento e a mensagem do Discord.
Configurações¶
Bot(voice=voice.Defaults(...)) define com o que toda conexão começa, e connect() aceita as configurações de conexão para uma chamada:
Campo de voice.Defaults |
Padrão | Significado |
|---|---|---|
self_mute, self_deaf |
False |
o próprio silenciamento e ensurdecimento do bot |
timeout |
10.0 | segundos para esperar uma entrada, uma mudança de canal ou uma edição |
server_change_delay |
0.0 | segundos para esperar antes de seguir o Discord para um novo servidor de voz |
encoder |
EncoderSettings() |
o codificador Opus; veja Reproduzindo áudio |
speaking_flags |
MICROPHONE |
as voice.SpeakingFlags que o bot anuncia quando reproduz |
stage |
"listen" |
em um palco: "listen", "request" para pedir para falar, ou "speak" |
speech_hangover |
0.2 | segundos de silêncio antes de um falante contar como parado, de 0.02 a 10 |
speech_activity |
True |
se os eventos de início e fim de fala disparam |
stall_after |
2.0 | segundos que a mídia pode ficar parada antes que MediaStalled o informe, no mínimo 0.02 |
sink_buffer, stall |
0.2, "hold" |
os padrões de todo sink |
receive_buffer, jitter, overflow, receive_stall |
2.0, 0.1, "drop_oldest", "wait" |
os padrões de todo receptor |
crossfade, history |
0.0, 0 | os padrões de toda fila |
connect() aceita self_mute, self_deaf, timeout, server_change_delay, encoder, speaking_flags, stage, speech_hangover, speech_activity e stall_after como argumentos nomeados; um omitido, ou None, mantém o padrão. connect_external() aceita os três primeiros, move() aceita timeout, e edit() aceita self_mute, self_deaf e timeout. Valores inválidos levantam ValueError quando o Defaults é montado. As configurações em string têm os tipos literais voice.StageRole, voice.SinkStall, voice.ReceiveStall, voice.Overflow, voice.Sample, voice.OpusApplication, voice.OpusBandwidth e voice.OpusSignal. ctx.voice é um voice.ContextVoice e bot.voice um voice.VoiceManager.
A conexão¶
VoiceConnection |
Valor |
|---|---|
guild_id, channel_id |
onde ela está |
state |
um voice.State: CONNECTING, READY, RESUMING, RECONNECTING ou DISCONNECTED |
members |
os IDs dos outros usuários na chamada |
speaking |
os usuários falando agora, mapeados para o time.monotonic() em que começaram |
ssrcs |
cada SSRC na chamada mapeado para o ID do seu usuário |
self_mute, self_deaf |
o próprio silenciamento e ensurdecimento do bot, como o Discord os aplicou |
mute, deaf, suppress |
se um moderador silenciou ou ensurdeceu o bot, ou se ele não pode falar neste palco |
latency |
uma Latency com os tempos de ida e volta do WebSocket e do UDP em segundos, None até serem medidos |
dave |
a criptografia de ponta a ponta da chamada; veja abaixo |
sinks |
os sinks abertos, do mais antigo para o mais novo |
encoder, speaking_flags, speech_hangover, speech_activity, stall_after |
podem ser definidos enquanto conectado |
await vc.move(channel_id)passa para outro canal de voz ou de palco do servidor e retorna assim que a mídia flui lá.await vc.edit(self_mute=..., self_deaf=...)muda o próprio silenciamento ou ensurdecimento do bot, deixando como está o que não for passado, e retorna assim que o Discord o aplicou.await vc.stage("request")pede para falar em um palco,"speak"fala (com a permissão para isso),"listen"volta para a plateia.await vc.wait_until(voice.State.READY)espera um estado e levantaVoiceNotConnectedse a conexão terminar antes.vc.reconnect()derruba o WebSocket de voz e retoma em um novo.vc.stop()fecha todos os sinks, então nada toca.await vc.disconnect()sai do canal e retorna assim que a conexão terminou, no máximo 2 segundos depois.
Chamadas em uma conexão que terminou levantam VoiceNotConnected.
A conexão se recupera sozinha. Um WebSocket que caiu é retomado, uma sessão perdida se reconecta e, quando um moderador move o bot, ele segue para o novo canal e envia um evento Moved. Quando um moderador o desconecta ou exclui o canal, ela termina com o motivo REMOVED. O bostie avisa quando o bot reproduz enquanto está silenciado no servidor ou suprimido em um palco, e quando os receptores esperam enquanto ele está ensurdecido.
Criptografia de ponta a ponta¶
O bostie fala DAVE, a criptografia de ponta a ponta do Discord para voz, sem nada para configurar: ele entra no grupo de criptografia da chamada, criptografa o que envia e descriptografa o que recebe. vc.dave a informa como um voice.Dave:
protocol_versioné o protocolo DAVE em uso, 0 para nenhum;epochconta as mudanças dos membros do grupo;privacy_codeé o código que os clientes do Discord mostram para a chamada;await vc.dave.verification_code(user_id)retorna o código a comparar com outro usuário por fora do Discord, para verificar que ninguém mais está no meio.
Um evento DaveTransition informa cada mudança. Um canal que exige DAVE de um cliente que não o oferece é fechado com o motivo DAVE_REQUIRED, e uma etapa do DAVE que falha levanta DaveError ou encerra a conexão com DAVE_FAILED.
Eventos¶
As conexões de voz enviam os seus eventos por @bot.listen. Cada um é um voice.VoiceEvent com o guild_id. Um StateChange para DISCONNECTED traz um voice.DisconnectReason: REQUESTED (disconnect() rodou), REMOVED (um moderador desconectou o bot ou excluiu o canal), CALL_TERMINATED, RATE_LIMITED, TIMEOUT, SESSION_INVALID, AUTHENTICATION_FAILED, DAVE_REQUIRED, DAVE_FAILED, PROTOCOL_ERROR ou CLOSED (o bot fechou).
| Evento | Campos | Enviado quando |
|---|---|---|
StateChange |
old, new, reason, code, detail |
a conexão mudou de estado |
Moved |
old_channel_id, new_channel_id, by_us |
o bot mudou de canal, por move() ou por um moderador |
DaveTransition |
protocol_version, epoch, transition_id |
a criptografia de ponta a ponta mudou |
MediaStalled, MediaResumed |
direction (voice.Direction.SEND ou RECEIVE), duration |
o envio ou a recepção parou, ou recomeçou |
UserJoin, UserLeave |
user_id |
um usuário entrou na chamada ou saiu dela |
SpeakingUpdate |
user_id, ssrc, flags |
um usuário anunciou o seu SSRC e as suas flags de fala |
SpeechStart, SpeechStop |
user_id, ssrc, at e, no fim, duration |
um usuário começou ou parou de falar |
RtcpPacket |
packet_type, count, ssrc, user_id, reports, data |
o servidor de voz enviou um pacote RTCP; cada ReceptionReport em reports tem o fraction_lost, o lost, o highest_sequence, o jitter, o last_sender_report e o delay de um stream |
Eles ficam em bostie.voice. SpeechStart e SpeechStop só disparam enquanto speech_activity está ativado.
Mídia parada¶
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 com direction SEND dispara quando o áudio espera em um sink mas não consegue sair por stall_after segundos, durante uma reconexão ou uma transição do DAVE. Com RECEIVE, os pacotes chegam a um receptor aberto, mas nenhum pode ser aberto ou reproduzido por esse tempo. MediaResumed vem em seguida assim que a mídia volta a fluir, com o seu duration contado a partir do início da parada. stall_after é 2 segundos, a menos que voice.Defaults, connect(stall_after=...) ou vc.stall_after diga outra coisa.
Saindo de um canal vazio¶
O bot fica em um canal até se desconectar. Para sair quando o último usuário sair:
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()
Erros¶
Os erros de voz são VoiceErrors: VoiceConnectError (uma entrada terminou antes de ficar pronta; reason, code, detail), NotInVoice, VoiceNotConnected, AlreadyConnected, VoiceQueueFull (comandos demais esperando pela conexão), DaveError e, para áudio, SourceError, SinkClosed, BufferFull, Empty e ReceiverClosed, que as próximas páginas explicam.
Motores de voz externos¶
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) entra em um canal para um motor de voz que roda em outro lugar e retorna um ExternalVoice com o state (VoiceStateUpdate) e o server (VoiceServerUpdate) com que o motor se conecta. O bostie então não envia áudio nesse servidor. async with external.updates() as updates: entrega os servidores de voz seguintes do servidor e os próprios estados de voz do bot, e await external.disconnect() sai do canal. Ele levanta AlreadyConnected em um servidor onde o bostie roda a sua própria conexão, e aceita self_mute, self_deaf e timeout.
Versões¶
voice.versions() retorna as versões do bostie, da libopus, da biblioteca DAVE e do protocolo DAVE mais alto compilados na build.