Saltar a contenido

Conexiones de voz

Bostie escucha con auriculares

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 lanza VoiceNotConnected si 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_version es el protocolo DAVE en uso, 0 si no hay ninguno;
  • epoch cuenta los cambios de los miembros del grupo;
  • privacy_code es 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.