Перейти к содержанию

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

Бости держит микрофон, к которому приходят звуковые волны

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.

Сообщите людям в звонке, что бот их записывает, как это делает пример.