Приём и запись¶

vc.receive() открывает приёмник: ограниченный асинхронный итератор по звуку, который слышит бот. Каждый говорящий приходит отдельно — расшифрованным, упорядоченным и декодированным на Rust. На одном подключении можно открыть несколько приёмников, каждый со своим форматом и фильтрами.
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),
)
Форматы¶
vc.receive(format) возвращает voice.Receiver для voice.ReceiveFormat:
vc.receive(format) |
Элементы | Для чего |
|---|---|---|
voice.PCM(rate=48000, channels=2, sample="s16le") |
объекты Frame с декодированными отсчётами в этом формате; частота — одна из voice.DECODE_RATES: 8, 12, 16, 24 или 48 кГц |
прослушивание, распознавание речи, уровни |
voice.Opus() |
объекты Frame, чьё data — один пакет Opus |
запись или пересылка без декодирования |
voice.Packets() |
объекты Packet, расшифрованные, в порядке прихода, до джиттер-буфера |
собственный джиттер-буфер, расширения заголовка RTP |
У Frame есть user_id, ssrc, RTP-timestamp, offset и samples по часам приёмника 48 кГц, received_at, data, lost (истина для звука, который Opus досочинил, чтобы скрыть потерянный пакет), а для PCM — level, среднеквадратичный уровень кадра в dBFS. Часы запускаются при открытии приёмника, поэтому кадры разных пользователей выравниваются по offset. У Packet есть user_id, ssrc, sequence, timestamp, received_at, полезная нагрузка opus и расширения заголовка RTP extensions.
Кого слушать¶
users=[...]оставляет только этих пользователей, аexclude=[...]исключает некоторых.include_self=Trueдобавляет собственный звук бота.mix=True(PCM и Opus) выдаёт один поток, в котором смикшированы все, сuser_id, равнымNone.markers=Trueвставляет между кадрами событияSpeechStart,SpeechStopиUserLeave, каждое со своимoffsetпо тем же часам, так что потребитель знает, где речь начинается и заканчивается, не замеряя время сам.
Настройки¶
vc.receive принимает эти именованные аргументы; у Packets() нет джиттер-буфера, поэтому он не принимает jitter.
| Настройка | По умолчанию | Значение |
|---|---|---|
buffer |
Defaults.receive_buffer, 2.0 |
сколько секунд элементов приёмник держит для медленного потребителя, от 0,02 до 60 |
overflow |
Defaults.overflow, "drop_oldest" |
что делать, когда буфер заполнен: отбрасывать самые старые элементы или "drop_newest" |
jitter |
Defaults.jitter, 0.1 |
сколько секунд ждать опоздавшие пакеты, прежде чем играть дальше, от 0 до 1; только PCM и Opus |
stall |
Defaults.receive_stall, "wait" |
что делать при переподключении: "wait" оставляет приёмник открытым, "end" завершает его |
Чтение¶
async for item in rx ждёт элементы и останавливается, когда приёмник завершается. async with rx: закрывает его в конце блока.
rx.get_nowait()возвращает следующий элемент или вызываетvoice.Empty, если очередь пуста, иvoice.ReceiverClosed, если приёмник завершился.rx.queued— сколько ждёт самый старый элемент в очереди: отставание потребителя.rx.clear()отбрасывает всё, что стоит в очереди, и продолжает в реальном времени.await rx.mark()возвращаетTrue, как только звук, полученный до вызова, поставлен в очередь или отброшен, илиFalseчерез 1,2 секунды плюс удвоенный джиттер; отмена одного ожидания оставляет метку для остальных.rx.stats()возвращаетReceiverStats: элементы доставленные (delivered), отброшенные при переполнении (dropped) и пакеты, потерянные по пути (lost).rx.close()прекращает приём.rx.closed_reason,voice.ClosedReason, говорит, почему приёмник завершился:CLOSED,DISCONNECTED,MOVED(бот сменил канал) илиRECONNECTING(приstall="end").
Модератор может отключить боту звук, и тогда Discord не присылает ему никакого звука; bostie пишет предупреждение в лог, когда приёмник открывается при отключённом у бота звуке и когда боту отключают звук при открытых приёмниках.
Ретрансляция¶
Приёмник и синк соединяются напрямую, чтобы переносить звук из одного звонка в другой:
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)
У смикшированного кадра нет user_id, ssrc и timestamp, а микс не может нести маркеры. У потерянного кадра Opus пустое data, потому что передавать нечего; пропускайте его перед записью в синк Opus, как делает relay_speaker. Обе стороны по умолчанию используют стерео PCM 16 бит 48 кГц, так что приёмник PCM пишет прямо в синк по умолчанию.
Речь¶
Определение речи работает на Rust в каждом подключении, пока включён speech_activity: пользователь начинает говорить со своим первым звуком и перестаёт после speech_hangover секунд тишины, по умолчанию 0,2. vc.speaking хранит, кто говорит сейчас, события SpeechStart и SpeechStop сообщают об этом слушателям, а приёмники, открытые с markers=True, несут это в своём потоке. Если speech_activity выключен и ни один приёмник не открыт, bostie даже не расшифровывает входящий звук.
voice.utterances(rx, min_silence=0.8, max_length=30.0) группирует кадры приёмника PCM в объекты Utterance: речь одного пользователя от начала до конца, с data в формате приёмника, её offset, duration и потерянными секундами lost. Высказывание заканчивается после min_silence секунд без речи или на max_length секундах. Для этого нужен приёмник PCM без mix, открытый с markers=True, или level в dBFS, который тогда вместо маркеров решает, что считать речью.
Запись¶
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) записывает каждого пользователя в отдельный файл или, с mix=True, весь звонок в один. Путь — это шаблон с {guild_id} и {user_id}: шаблон обязателен для отдельного файла на пользователя и не допускается с mix=True, а расширение выбирает контейнер:
.oggили.opusпишет Ogg Opus. Для каждого пользователя полученные пакеты Opus пишутся как есть, без декодирования..wavпишет PCM 16 бит в формате рекордераformat, по умолчанию стерео 48 кГц; его частота — одна изvoice.DECODE_RATES.
С align=True (по умолчанию) промежутки заполняются тишиной, так что каждый файл начинается с момента начала записи и файлы выравниваются между собой. users, exclude и include_self выбирают, кого записывать, как и для приёмника, а buffer (30 секунд) и jitter (0,3 секунды) больше, чем значения по умолчанию у приёмника, потому что диск может подтормаживать. Запись заканчивается сама через max_duration секунд, или когда её файлы достигают max_size байт, или когда завершается подключение.
await recorder.stop() завершает запись, как только уже услышанный звук записан, и возвращает файлы в виде словаря из ID пользователя в Path, где ключ смикшированного файла — None. await recorder.wait() ждёт, пока запись закончится сама. recorder.files перечисляет записанные на данный момент файлы, а recorder.stats() — счётчики приёмника, где dropped означает, что диск отставал дольше, чем на buffer.
Сообщите людям в звонке, что бот их записывает, как это делает пример.