Conexiones de voz¶

El motor de voz de bostie corre en su núcleo en Rust: el WebSocket de voz, el transporte UDP, el reloj de envío de 20 ms, la decodificación y el remuestreo, el mezclador, la codificación y decodificación Opus, el cifrado del transporte y el cifrado de extremo a extremo DAVE. Python le pide que se una, reproduzca y reciba; los tiempos nunca dependen del event loop, salvo que Python tiene que mantener lleno el búfer de cada sink.
La voz necesita Intents.GUILD_VOICE_STATES, que está incluido en Intents.DEFAULT. El bot tiene como máximo una conexión 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)
Conectarse¶
await ctx.voice.connect() se une al canal de voz o canal escenario del usuario que ejecutó el comando, y retorna cuando el audio ya puede fluir en los dos sentidos. Busca el canal del usuario en la caché, o se lo pregunta a Discord cuando el servidor no está en caché, y lanza NotInVoice cuando el usuario no está en ningún canal de voz del servidor o la interacción vino de un MD. ctx.voice.current es la conexión del bot en ese servidor, o None.
Fuera de un handler, await bot.voice.connect(channel_id) se une a un canal por id. En un servidor donde el bot ya está conectado, mueve la conexión y aplica solo las opciones indicadas. bot.voice.get(guild_id) y bot.voice.connections devuelven las conexiones abiertas, y await bot.voice.close() sale de todas, como hace bot.close().
Una unión que no está lista en timeout segundos lanza VoiceConnectError con el motivo TIMEOUT; Discord deja sin respuesta la unión a un canal lleno, o a uno al que el bot no tiene permiso para conectarse. Los demás fallos lanzan VoiceConnectError con el motivo, el código de cierre y el mensaje de Discord.
Ajustes¶
Bot(voice=voice.Defaults(...)) define con qué arranca cada conexión, y connect() acepta los ajustes de conexión para una llamada:
Campo de voice.Defaults |
Por defecto | Significado |
|---|---|---|
self_mute, self_deaf |
False |
el silencio y el ensordecimiento propios del bot |
timeout |
10.0 | segundos de espera para unirse, moverse o editar |
server_change_delay |
0.0 | segundos de espera antes de seguir a Discord a un nuevo servidor de voz |
encoder |
EncoderSettings() |
el codificador Opus; ver Reproducir audio |
speaking_flags |
MICROPHONE |
los voice.SpeakingFlags que el bot anuncia cuando reproduce |
stage |
"listen" |
en un escenario: "listen", "request" para pedir la palabra, o "speak" |
speech_hangover |
0.2 | segundos de silencio antes de que se considere que alguien dejó de hablar, de 0.02 a 10 |
speech_activity |
True |
si se disparan los eventos de inicio y fin de habla |
stall_after |
2.0 | segundos que el flujo de medios puede detenerse antes de que MediaStalled lo informe, como mínimo 0.02 |
sink_buffer, stall |
0.2, "hold" |
los valores por defecto de cada sink |
receive_buffer, jitter, overflow, receive_stall |
2.0, 0.1, "drop_oldest", "wait" |
los valores por defecto de cada receptor |
crossfade, history |
0.0, 0 | los valores por defecto de cada cola |
connect() acepta self_mute, self_deaf, timeout, server_change_delay, encoder, speaking_flags, stage, speech_hangover, speech_activity y stall_after como argumentos por palabra clave; uno que no se pasa, o None, conserva el valor por defecto. connect_external() acepta los tres primeros, move() acepta timeout, y edit() acepta self_mute, self_deaf y timeout. Los valores inválidos lanzan ValueError cuando se construye el Defaults. Los ajustes de tipo cadena tienen los tipos literales voice.StageRole, voice.SinkStall, voice.ReceiveStall, voice.Overflow, voice.Sample, voice.OpusApplication, voice.OpusBandwidth y voice.OpusSignal. ctx.voice es un voice.ContextVoice y bot.voice un voice.VoiceManager.
La conexión¶
VoiceConnection |
Valor |
|---|---|
guild_id, channel_id |
dónde está |
state |
un voice.State: CONNECTING, READY, RESUMING, RECONNECTING o DISCONNECTED |
members |
los ids de los demás usuarios de la llamada |
speaking |
los usuarios que están hablando ahora, asociados al time.monotonic() en que empezaron |
ssrcs |
cada SSRC de la llamada asociado al id de su usuario |
self_mute, self_deaf |
el silencio y el ensordecimiento propios del bot, tal como los aplicó Discord |
mute, deaf, suppress |
si un moderador silenció o ensordeció al bot, o si no puede hablar en este escenario |
latency |
una Latency con los tiempos de ida y vuelta del WebSocket y de UDP en segundos, None hasta que se miden |
dave |
el cifrado de extremo a extremo de la llamada; ver abajo |
sinks |
los sinks abiertos, del más antiguo al más nuevo |
encoder, speaking_flags, speech_hangover, speech_activity, stall_after |
se pueden cambiar mientras está conectado |
await vc.move(channel_id)se mueve a otro canal de voz o canal escenario del servidor y retorna cuando los medios ya fluyen ahí.await vc.edit(self_mute=..., self_deaf=...)cambia el silencio o el ensordecimiento propios del bot, deja como está el que no se indica y retorna cuando Discord lo aplicó.await vc.stage("request")pide la palabra en un escenario,"speak"habla (si tiene el permiso) y"listen"vuelve a la audiencia.await vc.wait_until(voice.State.READY)espera un estado, y lanzaVoiceNotConnectedsi la conexión termina antes.vc.reconnect()corta el WebSocket de voz y reanuda en uno nuevo.vc.stop()cierra todos los sinks, así que no se reproduce nada.await vc.disconnect()sale del canal y retorna cuando terminó la conexión, como máximo 2 segundos después.
Las llamadas sobre una conexión que terminó lanzan VoiceNotConnected.
La conexión se repara sola. Un WebSocket que se cae se reanuda, una sesión perdida se reconecta y, cuando un moderador mueve al bot, este lo sigue al nuevo canal y envía un evento Moved. Cuando un moderador lo desconecta o elimina el canal, termina con el motivo REMOVED. bostie advierte cuando el bot reproduce mientras está silenciado por el servidor o sin permiso para hablar en un escenario, y cuando los receptores esperan mientras está ensordecido.
Cifrado de extremo a extremo¶
bostie habla DAVE, el cifrado de extremo a extremo de Discord para la voz, sin nada que configurar: se une al grupo de cifrado de la llamada, cifra lo que envía y descifra lo que recibe. vc.dave lo informa como un voice.Dave:
protocol_versiones el protocolo DAVE en uso, 0 si no hay ninguno;epochcuenta los cambios de los miembros del grupo;privacy_codees el código que muestran los clientes de Discord para la llamada;await vc.dave.verification_code(user_id)devuelve el código que hay que comparar con otro usuario por un canal aparte, para verificar que nadie más está en el medio.
Un evento DaveTransition informa cada cambio. Un canal que exige DAVE a un cliente que no lo ofrece se cierra con el motivo DAVE_REQUIRED, y un paso de DAVE fallido lanza DaveError o termina la conexión con DAVE_FAILED.
Eventos¶
Las conexiones de voz envían sus eventos por @bot.listen. Cada uno es un voice.VoiceEvent con el guild_id. Un StateChange hacia DISCONNECTED trae un voice.DisconnectReason: REQUESTED (se ejecutó disconnect()), REMOVED (un moderador desconectó al bot o eliminó el canal), CALL_TERMINATED, RATE_LIMITED, TIMEOUT, SESSION_INVALID, AUTHENTICATION_FAILED, DAVE_REQUIRED, DAVE_FAILED, PROTOCOL_ERROR o CLOSED (el bot se cerró).
| Evento | Campos | Se envía cuando |
|---|---|---|
StateChange |
old, new, reason, code, detail |
la conexión cambió de estado |
Moved |
old_channel_id, new_channel_id, by_us |
el bot se movió, por move() o por un moderador |
DaveTransition |
protocol_version, epoch, transition_id |
cambió el cifrado de extremo a extremo |
MediaStalled, MediaResumed |
direction (voice.Direction.SEND o RECEIVE), duration |
el envío o la recepción se detuvo, o se reanudó |
UserJoin, UserLeave |
user_id |
un usuario se unió a la llamada o salió de ella |
SpeakingUpdate |
user_id, ssrc, flags |
un usuario anunció su SSRC y sus flags de habla |
SpeechStart, SpeechStop |
user_id, ssrc, at, y duration al terminar |
un usuario empezó o dejó de hablar |
RtcpPacket |
packet_type, count, ssrc, user_id, reports, data |
el servidor de voz envió un paquete RTCP; cada ReceptionReport de reports tiene el fraction_lost, lost, highest_sequence, jitter, last_sender_report y delay de un stream |
Están en bostie.voice. SpeechStart y SpeechStop solo se disparan mientras speech_activity está activado.
Medios detenidos¶
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 con direction SEND se dispara cuando hay audio esperando en un sink pero no puede salir durante stall_after segundos, en una reconexión o en una transición de DAVE. Con RECEIVE, llegan paquetes a un receptor abierto pero ninguno se puede abrir ni reproducir durante ese tiempo. MediaResumed sigue cuando los medios vuelven a fluir, y su duration cuenta desde el inicio de la detención. stall_after es de 2 segundos salvo que voice.Defaults, connect(stall_after=...) o vc.stall_after indiquen otra cosa.
Salir de un canal vacío¶
El bot se queda en un canal hasta que se desconecta. Para salir cuando sale el último usuario:
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()
Errores¶
Los errores de voz son VoiceError: VoiceConnectError (una unión terminó antes de estar lista; reason, code, detail), NotInVoice, VoiceNotConnected, AlreadyConnected, VoiceQueueFull (demasiados comandos esperando a la conexión), DaveError y, para el audio, SourceError, SinkClosed, BufferFull, Empty y ReceiverClosed, que explican las páginas siguientes.
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) se une a un canal para un motor de voz que corre en otro lugar, y devuelve un ExternalVoice con el state (VoiceStateUpdate) y el server (VoiceServerUpdate) con los que se conecta el motor. bostie no envía entonces ningún audio en ese servidor. async with external.updates() as updates: entrega los servidores de voz posteriores del servidor y los estados de voz propios del bot, y await external.disconnect() sale del canal. Lanza AlreadyConnected en un servidor donde bostie ejecuta su propia conexión, y acepta self_mute, self_deaf y timeout.
Versiones¶
voice.versions() devuelve las versiones de bostie, de libopus, de la biblioteca DAVE y del protocolo DAVE más alto compiladas en la build.