Recebendo e gravando¶

vc.receive() abre um receptor: um iterador assíncrono limitado sobre o áudio que o bot ouve. Cada falante chega separadamente, descriptografado, reordenado e decodificado em Rust. Vários receptores podem estar abertos em uma conexão, cada um com o seu próprio formato e os seus próprios filtros.
import logging
import bostie
from bostie import Context, voice
log = logging.getLogger("my-bot")
bot = bostie.Bot()
@bot.command
async def meter(ctx: Context, seconds: int = 10) -> None:
"""Log how loud each speaker is for a few seconds."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Listening.")
loudest: dict[int, float] = {}
async with vc.receive(voice.PCM(rate=16_000, channels=1)) as frames:
async for frame in frames:
if frame.user_id is not None and frame.level is not None:
level = max(loudest.get(frame.user_id, frame.level), frame.level)
loudest[frame.user_id] = level
if frame.offset >= seconds * 48_000:
break
for user_id, level in loudest.items():
log.info("user %d peaked at %.1f dBFS", user_id, level)
@bot.command
async def transcribe(ctx: Context) -> None:
"""Collect what each person says, one utterance at a time."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Transcribing until the bot leaves.")
rx = vc.receive(voice.PCM(rate=16_000, channels=1), markers=True)
async for said in voice.utterances(rx, min_silence=0.6, max_length=20.0):
log.info(
"user %d spoke %.1f s (%d bytes of 16 kHz mono)",
said.user_id,
said.duration,
len(said.data),
)
Formatos¶
vc.receive(format) retorna um voice.Receiver para um voice.ReceiveFormat:
vc.receive(format) |
Itens | Para |
|---|---|---|
voice.PCM(rate=48000, channels=2, sample="s16le") |
Frames de amostras decodificadas nesse formato; a taxa é uma de voice.DECODE_RATES, 8, 12, 16, 24 ou 48 kHz |
ouvir, reconhecimento de fala, níveis |
voice.Opus() |
Frames cujo data é um pacote Opus |
gravar ou encaminhar sem decodificar |
voice.Packets() |
Packets, descriptografados, na ordem de chegada, antes do buffer de jitter |
o seu próprio buffer de jitter, extensões de cabeçalho RTP |
Um Frame tem o user_id, o ssrc, o timestamp RTP, offset e samples no relógio de 48 kHz do receptor, received_at, o data, lost (verdadeiro para áudio que o Opus inventou para disfarçar um pacote perdido) e, para PCM, o level, o RMS do frame em dBFS. O relógio começa quando o receptor abre, então os frames de usuários diferentes se alinham pelo offset. Um Packet tem o user_id, o ssrc, o sequence, o timestamp, o received_at, o payload opus e as extensions do cabeçalho RTP.
Escolhendo quem ouvir¶
users=[...]mantém só esses usuários, eexclude=[...]descarta alguns.include_self=Trueadiciona o próprio áudio do bot.mix=True(PCM e Opus) entrega um único stream com todos mixados, comuser_iddefinido comoNone.markers=Truecoloca eventosSpeechStart,SpeechStopeUserLeaveentre os frames, cada um com o seuoffsetno mesmo relógio, para que um consumidor saiba onde a fala começa e termina sem cronometrá-la.
Configurações¶
vc.receive aceita estes argumentos nomeados; Packets() não tem buffer de jitter, então não aceita jitter.
| Configuração | Padrão | Significado |
|---|---|---|
buffer |
Defaults.receive_buffer, 2.0 |
segundos de itens que o receptor guarda para um consumidor lento, de 0.02 a 60 |
overflow |
Defaults.overflow, "drop_oldest" |
quando o buffer está cheio: descarta os itens mais antigos, ou "drop_newest" |
jitter |
Defaults.jitter, 0.1 |
segundos para esperar pacotes atrasados antes de seguir tocando, de 0 a 1; só PCM e Opus |
stall |
Defaults.receive_stall, "wait" |
quando a conexão se reconecta: "wait" mantém o receptor aberto, "end" o encerra |
Lendo¶
async for item in rx espera os itens e para quando o receptor termina. async with rx: o fecha quando o bloco termina.
rx.get_nowait()retorna o próximo item, ou levantavoice.Emptyquando não há nada na fila evoice.ReceiverClosedquando o receptor terminou.rx.queuedé quanto tempo o item mais antigo da fila esperou: o atraso do consumidor.rx.clear()descarta tudo o que está na fila e continua ao vivo.await rx.mark()retornaTrueassim que o áudio recebido antes da chamada está na fila ou foi descartado, ouFalsedepois de 1,2 segundo mais duas vezes o jitter; cancelar uma espera deixa a marca para as outras.rx.stats()retorna umReceiverStats: os itens entregues (delivered), os descartados por estouro (dropped) e os pacotes perdidos no caminho (lost).rx.close()para de receber.rx.closed_reason, umvoice.ClosedReason, diz por que um receptor terminou:CLOSED,DISCONNECTED,MOVED(o bot mudou de canal) ouRECONNECTING(comstall="end").
Um moderador pode ensurdecer o bot, e o Discord então não envia áudio a ele; o bostie escreve um aviso no log quando um receptor abre enquanto o bot está ensurdecido, e quando o bot é ensurdecido enquanto há receptores abertos.
Retransmitindo¶
Um receptor e um sink se conectam diretamente, para levar áudio de uma chamada para outra:
from bostie import voice
async def relay_call(
source: voice.VoiceConnection, target: voice.VoiceConnection
) -> None:
async with source.receive(voice.PCM(), mix=True) as rx, target.sink() as out:
async for frame in rx:
await out.write(frame.data)
async def relay_speaker(
source: voice.VoiceConnection, target: voice.VoiceConnection, user_id: int
) -> None:
async with (
source.receive(voice.Opus(), users=[user_id]) as rx,
target.sink(voice.Opus()) as out,
):
async for frame in rx:
if not frame.lost:
await out.write(frame.data)
Um frame mixado não tem user_id, ssrc nem timestamp, e uma mixagem não pode trazer marcadores. Um frame Opus perdido tem data vazio, porque não há pacote para repassar; pule-o antes de escrever em um sink Opus, como relay_speaker faz. As duas pontas têm como padrão PCM estéreo de 16 bits a 48 kHz, então um receptor PCM escreve direto em um sink padrão.
Fala¶
A detecção de fala roda em Rust em toda conexão enquanto speech_activity está ativado: um usuário começa a falar com o seu primeiro áudio e para depois de speech_hangover segundos de silêncio, 0.2 por padrão. vc.speaking guarda quem está falando agora, os eventos SpeechStart e SpeechStop informam isso aos listeners, e os receptores abertos com markers=True o trazem no seu stream. Com speech_activity desativado e nenhum receptor aberto, o bostie nem descriptografa o áudio que chega.
voice.utterances(rx, min_silence=0.8, max_length=30.0) agrupa os frames de um receptor PCM em Utterances: a fala de um usuário do início ao fim, com data no formato do receptor, o seu offset, a duration e os segundos perdidos (lost). Um enunciado termina depois de min_silence segundos sem fala, ou em max_length segundos. Ele precisa de um receptor PCM sem mix, aberto com markers=True, ou de um level em dBFS, que então decide o que conta como fala no lugar dos marcadores.
Gravação¶
import bostie
from bostie import Context, voice
bot = bostie.Bot()
@bot.command
async def record_call(ctx: Context, minutes: float = 5.0) -> None:
"""Record the whole call as one WAV file, for at most a few minutes."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Recording. Everyone in the call can see the bot.")
recorder = voice.Recorder(
vc,
"recordings/{guild_id}/call.wav",
mix=True,
max_duration=minutes * 60,
max_size=200_000_000,
)
files = await recorder.wait()
await ctx.followup(f"Saved {files[None]}; {recorder.stats().lost} packets lost.")
voice.Recorder(vc, path) grava cada usuário no seu próprio arquivo ou, com mix=True, a chamada inteira em um só. O caminho é um modelo com {guild_id} e {user_id}, obrigatório para um arquivo por usuário e recusado com mix=True, e a extensão dele escolhe o contêiner:
.oggou.opusgrava Ogg Opus. Por usuário, os pacotes Opus recebidos são gravados como estão, sem decodificação..wavgrava PCM de 16 bits noformatdo gravador, 48 kHz estéreo por padrão; a taxa dele é uma devoice.DECODE_RATES.
Com align=True, o padrão, o silêncio preenche os intervalos, então cada arquivo começa quando a gravação começou e os arquivos ficam alinhados. users, exclude e include_self escolhem quem gravar, como para um receptor, e buffer (30 segundos) e jitter (0,3 segundo) são maiores que os padrões de um receptor, porque um disco pode travar. A gravação termina sozinha depois de max_duration segundos, ou assim que os arquivos dela atingem max_size bytes, ou quando a conexão termina.
await recorder.stop() a encerra assim que o áudio já ouvido estiver gravado e retorna os arquivos como um dict do ID do usuário para Path, com None como a chave de um arquivo mixado. await recorder.wait() espera que ela termine sozinha. recorder.files lista os arquivos gravados até agora, e recorder.stats() os contadores do receptor, em que dropped significa que o disco ficou para trás por mais tempo que buffer.
Avise as pessoas na chamada que o bot as está gravando, como o trecho de código faz.