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

Синки и сырой звук

Синк — это ограниченный буфер, в который Python пишет звук, а микшер на Rust читает его порциями по 20 мс. Каждый трек и каждая очередь играют через синк. Открывайте собственный синк для звука, который вы генерируете или получаете откуда-то ещё: из синтезатора, движка синтеза речи, потока из другой программы.

import math
import struct

import bostie
from bostie import Context, voice

bot = bostie.Bot()
RATE = 24_000


def tone(frequency: float, seconds: float) -> bytes:
    count = int(RATE * seconds)
    samples = (
        int(8000 * math.sin(2 * math.pi * frequency * index / RATE))
        for index in range(count)
    )
    return struct.pack(f"<{count}h", *samples)


@bot.command
async def beep(ctx: Context) -> None:
    """Play three generated beeps."""
    vc = ctx.voice.current or await ctx.voice.connect()
    await ctx.respond("Beeping.")
    async with vc.sink(voice.PCM(rate=RATE, channels=1), volume=0.5) as sink:
        for frequency in (440.0, 554.4, 659.3):
            await sink.write(tone(frequency, 0.3))
            await sink.write(bytes(RATE // 10 * 2))
        done = sink.mark()
        await sink.write(tone(880.0, 1.0))
        if await done:
            await ctx.followup("The three beeps played; one long one to go.")
    await ctx.followup(f"Done: {sink.stats().underruns} underruns.")


@bot.command
async def opus(ctx: Context) -> None:
    """Encode in Python and send the packets as they are."""
    vc = ctx.voice.current or await ctx.voice.connect()
    encoder = voice.OpusEncoder(voice.PCM(rate=RATE, channels=1))
    await ctx.respond("Sending.")
    async with vc.sink(voice.Opus()) as sink:
        for packet in [*encoder.encode(tone(330.0, 2.0)), *encoder.flush()]:
            await sink.write(packet)

Форматы

vc.sink(format) возвращает voice.Sink для того, что вы будете писать, — voice.Format:

  • voice.PCM(rate=48000, channels=2, sample="s16le"): чередующиеся отсчёты little-endian, "s16le" (16-битные целые) или "f32le" (32-битные числа с плавающей точкой), моно или стерео, от 8000 до 192 000 Гц. bostie пересэмплирует в 48 кГц на Rust; частота должна делить 48 кГц на блоки не больше 960 отсчётов, что выполняется для всех распространённых частот.
  • voice.Opus(): один пакет Opus на запись. Пакеты по 20 мс, 48 кГц стерео отправляются как записаны, пока синк играет один, с громкостью 1 и без фильтров; всё остальное декодируется и микшируется.

voice.OpusEncoder(pcm_format, encoder=EncoderSettings()) кодирует PCM в такие пакеты в процессе Python — для звука, который вы хотите закодировать один раз и отправить во много синков: encode(data) возвращает все готовые на данный момент пакеты, а flush() дополняет остаток тишиной. Его битрейт по умолчанию — 64 кбит/с, поскольку ему не за каким каналом следовать.

Запись

  • await sink.write(data) ставит звук в очередь и ждёт, пока в синке уже лежит buffer секунд. Это ожидание и есть обратное давление, которое держит ваш источник данных в темпе реального времени.
  • sink.write_nowait(data) ставит в очередь всю порцию сразу или вызывает BufferFull.
  • async with sink: в конце блока ждёт, пока всё записанное проиграется, а затем закрывает синк; исключение в блоке вместо этого отбрасывает то, что стоит в очереди.
  • await sink.drain() ждёт, пока проиграется всё записанное на данный момент; False, если часть была отброшена.
  • sink.clear() отбрасывает то, что стоит в очереди; clear(until=marker) — только часть до маркера, clear(since=marker) — только часть после него.
  • sink.close() отбрасывает то, что стоит в очереди, и закрывает синк. Запись в закрытый синк вызывает SinkClosed.

sink.mark() возвращает Marker, стоящий после записанного на данный момент звука. await marker возвращает True, когда весь звук до него проигран, или False, если часть была отброшена раньше; marker.done говорит, известно ли это уже. С помощью маркеров плеер узнаёт, когда трек действительно закончился, а не просто был записан.

Настройки

vc.sink принимает эти именованные аргументы; vc.play и voice.Queue тоже принимают buffer и stall.

Настройка По умолчанию Значение
buffer Defaults.sink_buffer, 0.2 сколько секунд синк держит, прежде чем write начнёт ждать, от 0,02 до 60
stall Defaults.stall, "hold" что делать, когда звук нельзя отправить, — во время переподключения или перехода DAVE: "hold" сохраняет его, чтобы проиграть потом, "drop" отбрасывает его в реальном времени — для живого звука, который не должен отставать
volume 1.0 умножает отсчёты; sink.volume меняет его во время воспроизведения
filters Filters() эффекты; sink.filters меняет их во время воспроизведения
after None другой синк этого подключения; этот молчит, пока тот не закроется или не доиграет то, что было записано до его последнего drain()
crossfade 0.0 вместе с after — на сколько секунд раньше начать и сделать плавный переход; синк тогда буферизует на столько же больше

after и crossfade — это то, как очередь играет без пауз: синк следующего трека заполняется, пока играет текущий, и подхватывает воспроизведение точно с нужного отсчёта.

Состояние и счётчики

sink.queued — сколько секунд звука ждёт в очереди, sink.passthrough — ушёл ли последний пакет так, как был записан, а sink.closed — закрыт ли синк. sink.stats() возвращает SinkStats:

Поле Значение
sent сколько секунд звука отправлено
position позиция воспроизведения в секундах, как её отсчитывают маркеры
underruns сколько раз синк опустел во время воспроизведения вне drain(): источник данных отстал
held_ticks сколько тактов синк удерживался, потому что звук нельзя было отправить
dropped сколько секунд отброшено при остановке с "drop"
passthrough_packets сколько пакетов отправлено как записано, без декодирования

Главное, за чем стоит следить, — опустошения (underruns): источник данных, который не успевает держаться на buffer секунд впереди, слишком медленный, или заблокирован цикл событий. vc.sinks перечисляет открытые синки подключения.