Sinks and raw audio¶
A sink is a bounded buffer that Python writes audio into and the Rust mixer reads from, 20 ms at a time. Every track and queue plays through one. Open your own for audio you generate or receive from elsewhere: a synthesizer, a text-to-speech engine, a stream from another program.
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)
Formats¶
vc.sink(format) returns a voice.Sink for what you will write, a voice.Format:
voice.PCM(rate=48000, channels=2, sample="s16le"): interleaved little-endian samples,"s16le"(16-bit integers) or"f32le"(32-bit floats), mono or stereo, at 8000 to 192 000 Hz. bostie resamples to 48 kHz in Rust; a rate must divide into 48 kHz in blocks of at most 960 samples, which every common rate does.voice.Opus(): one Opus packet per write. Packets of 20 ms at 48 kHz stereo are sent as written while the sink plays alone at volume 1 without filters; anything else is decoded and mixed.
voice.OpusEncoder(pcm_format, encoder=EncoderSettings()) encodes PCM into those packets in Python's process, for audio you want to encode once and send to many sinks: encode(data) returns every complete packet so far, and flush() pads the rest with silence. Its bitrate defaults to 64 kbps, since it has no channel to follow.
Writing¶
await sink.write(data)queues audio, and waits while the sink already holdsbufferseconds. That wait is the backpressure that keeps your producer in step with real time.sink.write_nowait(data)queues the whole chunk at once, or raisesBufferFull.async with sink:waits at the end of the block until everything written has played, then closes the sink; an exception in the block discards what is queued instead.await sink.drain()waits until everything written so far has played;Falseif some of it was dropped.sink.clear()drops what is queued;clear(until=marker)only the part before a marker,clear(since=marker)only the part after it.sink.close()discards what is queued and closes. Writing to a closed sink raisesSinkClosed.
sink.mark() returns a Marker after the audio written so far. await marker returns True once all audio before it played, or False if some was dropped first; marker.done says whether that is known yet. Markers are how a player knows when a track really ended, not just when it was written.
Settings¶
vc.sink takes these keyword arguments; vc.play and voice.Queue take buffer and stall too.
| Setting | Default | Meaning |
|---|---|---|
buffer |
Defaults.sink_buffer, 0.2 |
seconds the sink holds before write waits, 0.02 to 60 |
stall |
Defaults.stall, "hold" |
when audio cannot be sent, during a reconnect or a DAVE transition: "hold" keeps it to play afterwards, "drop" discards it in real time, for live audio that must not fall behind |
volume |
1.0 | multiplies the samples; sink.volume changes it while playing |
filters |
Filters() |
the effects; sink.filters changes them while playing |
after |
None |
another sink of this connection; this one stays silent until that one closes or plays out what was written before its last drain() |
crossfade |
0.0 | with after, seconds to start early and fade across; the sink then buffers that much more |
after and crossfade are how a queue plays without gaps: the next track's sink fills while the current one plays, and takes over at the exact sample.
State and counters¶
sink.queued is the seconds of audio waiting, sink.passthrough whether the last packet went out as written, and sink.closed whether it is closed. sink.stats() returns a SinkStats:
| Field | Value |
|---|---|
sent |
seconds of audio sent |
position |
the playhead in seconds, as markers count it |
underruns |
how often the sink ran dry while playing, outside a drain(): the producer fell behind |
held_ticks |
ticks the sink was held because audio could not be sent |
dropped |
seconds discarded by a "drop" stall |
passthrough_packets |
packets sent as written, without decoding |
Underruns are the number to watch: a producer that cannot keep buffer seconds ahead is too slow, or the event loop is blocked. vc.sinks lists a connection's open sinks.