Pular para conteúdo

Reproduzindo áudio

Bostie balançando a cabeça ao som da música

await vc.play(source) reproduz uma fonte em uma conexão de voz e retorna uma Track na hora. Várias faixas tocam ao mesmo tempo e são mixadas; uma Queue as toca uma depois da outra.

import bostie
from bostie import Context, voice

bot = bostie.Bot()
tracks: dict[int, voice.Track] = {}


@bot.command
async def play(ctx: Context, url: str) -> None:
    """Play a media URL."""
    vc = ctx.voice.current or await ctx.voice.connect()
    track = await vc.play(voice.Url(url), volume=0.8, meta=ctx.user.id)
    tracks[vc.guild_id] = track
    await ctx.respond(f"Playing {track.source.tags.title or url}.")
    ended = await track.wait()
    if ended.reason is voice.EndReason.ERROR:
        await ctx.followup(f"The track failed: {ended.error}")


@bot.command
async def pause(ctx: Context) -> None:
    """Pause or resume the track."""
    track = tracks.get(ctx.guild_id or 0)
    if track is None or track.ended is not None:
        await ctx.respond("Nothing is playing.", ephemeral=True)
        return
    if track.paused:
        track.resume()
    else:
        track.pause()
    await ctx.respond(f"At {track.position:.0f} s.")


@bot.command
async def seek(ctx: Context, seconds: float) -> None:
    """Jump to a point in the track."""
    track = tracks.get(ctx.guild_id or 0)
    if track is not None and track.source.seekable:
        await track.seek(seconds)
    await ctx.respond(f"Now at {seconds:.0f} s.")


@bot.command
async def bass(ctx: Context, gain: float = 6.0) -> None:
    """Boost the bass and slow down a little."""
    track = tracks.get(ctx.guild_id or 0)
    if track is not None:
        track.filters = voice.Filters(bass=gain, speed=0.9)
    await ctx.respond("Changed the filters.")

Fontes

Uma fonte é um áudio a reproduzir. Cada uma é usada uma vez: play a abre, abri-la depois que terminou levanta StateError, e source.again() retorna uma nova com o mesmo áudio, para tocá-lo duas vezes.

Fonte Reproduz Permite busca
voice.File(path) um arquivo local sim
voice.Bytes(data, hint=None) o conteúdo de um arquivo em memória; hint é uma extensão como "ogg" sim
voice.Url(url, headers=None) uma URL HTTP ou HTTPS direta de mídia, transmitida em stream, ou uma playlist HLS quando o servidor aceita requisições de intervalo; HLS não
voice.FFmpeg(args, executable="ffmpeg") a saída de um comando do ffmpeg, para formatos que o bostie não decodifica não
voice.Stream(items, format) um iterável assíncrono de blocos PCM ou pacotes Opus em format não

File, Bytes e Url são decodificados em Rust: Ogg (Opus, Vorbis), WebM e Matroska, MP4 e M4A (AAC), MP3, FLAC e WAV. Uma URL precisa apontar para a própria mídia, não para uma página web que a incorpora. Quando a conexão com o servidor cai, Url se reconecta até três vezes, depois de 0,5, 1 e 2 segundos, e continua de onde parou se o servidor aceitar requisições de intervalo. Uma playlist mestre HLS toca a sua versão de áudio, uma playlist ao vivo começa três segmentos antes do fim, e as playlists criptografadas e os segmentos por intervalo de bytes são recusados com SourceError. FFmpeg roda o programa com os seus argumentos de entrada e lê da saída dele PCM estéreo de 16 bits a 48 kHz; ele levanta SourceError quando o programa não está no PATH ou sai com erro. Uma fonte que não pode ser aberta, decodificada ou buscada levanta SourceError, ou encerra a sua faixa com o motivo ERROR.

Depois de open(), que define opened, uma fonte tem format, duration (segundos, None quando desconhecida), seekable e tags, um voice.Tags com o title, o artist e o album que o arquivo informa.

import asyncio
from collections.abc import AsyncIterator
from pathlib import Path

import bostie
from bostie import Context, voice

bot = bostie.Bot()
CHIME = Path("chime.ogg")


@bot.command
async def chime(ctx: Context) -> None:
    """Play a short sound from memory."""
    vc = ctx.voice.current or await ctx.voice.connect()
    data = await asyncio.to_thread(CHIME.read_bytes)
    await vc.play(voice.Bytes(data, hint="ogg"))
    await ctx.respond("Ding.")


@bot.command
async def radio(ctx: Context) -> None:
    """Play a station through ffmpeg."""
    vc = ctx.voice.current or await ctx.voice.connect()
    station = voice.FFmpeg(["-i", "https://example.com/radio.m3u8"])
    await vc.play(station)
    await ctx.respond("On air.")


async def silence(seconds: float) -> AsyncIterator[bytes]:
    frame = bytes(3840)
    for _ in range(int(seconds * 50)):
        yield frame


@bot.command
async def hush(ctx: Context) -> None:
    """Play three seconds of generated silence."""
    vc = ctx.voice.current or await ctx.voice.connect()
    track = await vc.play(voice.Stream(silence(3), voice.PCM()))
    await ctx.respond("Shh.")
    await track.wait()

Áudio Opus a 48 kHz estéreo em pacotes de 20 ms, como a maioria dos arquivos Ogg Opus e WebM, é enviado do jeito que está, sem decodificar e codificar de novo, enquanto toca sozinho no volume 1 sem filtros. track.passthrough diz quando isso acontece.

Faixas

vc.play(source, volume=1.0, filters=Filters(), meta=None) aceita o volume e os filtros iniciais, e meta, qualquer valor a guardar com a faixa, como quem a pediu. Ele também aceita buffer e stall para o sink da faixa.

Track Faz
await track.wait() espera o fim e retorna um TrackEnd; cancelar a espera não para a faixa
await track.started() espera até ela começar a tocar; False se ela terminou antes
track.ended o TrackEnd, ou None enquanto ela toca
track.position os segundos da fonte tocados até agora
track.pause(), track.resume(), track.paused param e retomam a alimentação dela; o áudio que já está no buffer continua tocando
await track.seek(seconds) continua a partir de uma posição; SourceError quando a fonte não permite busca
track.volume, track.filters mudam na hora enquanto ela toca
track.stop() a encerra
track.source, track.meta, track.sink o que ela toca, o seu valor e o sink em que ela escreve

Um TrackEnd tem a track, o reason (FINISHED, STOPPED, ERROR com o error, ou CLEANUP quando a conexão terminou) e may_start_next, verdadeiro depois de FINISHED ou ERROR, que diz se um player seu deve passar para a próxima faixa. vc.stop() encerra todas as faixas.

Volume e filtros

volume multiplica as amostras: 0.5 é metade do volume, 1.0 as deixa sem mudança. voice.Filters define efeitos que o Rust aplica enquanto o áudio toca:

Campo de Filters Padrão Faixa de valores
equalizer {} até 16 bandas, mapeando uma frequência central de 20 a 20 000 Hz para um ganho em dB; cada banda tem uma oitava de largura
bass 0.0 ganho em dB abaixo de 100 Hz
speed 1.0 de 0.5 a 2.0, mantendo a altura
pitch 1.0 de 0.5 a 2.0, mantendo o andamento

Os ganhos vão de -24 a 24 dB, e valores fora da faixa levantam ValueError.

Filas

import bostie
from bostie import Context, voice

bot = bostie.Bot(voice=voice.Defaults(crossfade=3.0, history=20))
queues: dict[int, voice.Queue] = {}


async def queue_of(ctx: Context) -> voice.Queue:
    vc = ctx.voice.current or await ctx.voice.connect()
    queue = queues.get(vc.guild_id)
    if queue is None or queue.closed:
        queue = queues[vc.guild_id] = voice.Queue(vc)
    return queue


@bot.command
async def add(ctx: Context, url: str, next_up: bool = False) -> None:
    """Add a URL to the queue, or play it next."""
    queue = await queue_of(ctx)
    queue.add(voice.Url(url), meta=ctx.user.id, index=0 if next_up else None)
    await ctx.respond(f"{len(queue.upcoming)} tracks up next.")


@bot.command
async def queue(ctx: Context) -> None:
    """Show what plays now and next."""
    player = await queue_of(ctx)
    lines = [f"Now: {player.now.source!r}" if player.now else "Nothing playing"]
    lines += [f"{i}. {track.source!r}" for i, track in enumerate(player.upcoming, 1)]
    await ctx.respond("\n".join(lines))


@bot.command
async def back(ctx: Context) -> None:
    """Play the previous track again."""
    player = await queue_of(ctx)
    previous = player.back()
    await ctx.respond("Going back." if previous else "Nothing played before.")


@bot.command
async def shuffle(ctx: Context) -> None:
    """Shuffle the upcoming tracks."""
    (await queue_of(ctx)).shuffle()
    await ctx.respond("Shuffled.")

voice.Queue(vc) toca as faixas uma atrás da outra sem intervalos, ou sobrepostas por crossfade segundos. Ela aceita volume, filters, crossfade e history, que têm como padrão as configurações da conexão, e o buffer e o stall do sink.

  • queue.add(source, meta=None, index=None) coloca uma fonte no fim da fila, ou em index entre as próximas faixas, e retorna a Track dela.
  • queue.now, queue.upcoming e queue.history são a faixa atual, as próximas e até history faixas que já tocaram.
  • queue.skip() encerra a faixa atual; queue.back() toca de novo a última faixa do histórico e depois a atual do começo.
  • queue.move(track, index), queue.remove(track), queue.shuffle() e queue.clear() reorganizam as próximas faixas.
  • queue.loop = "track" repete a faixa atual, "queue" a fila inteira, None para de repetir; o tipo é voice.Loop. As repetições usam source.again().
  • queue.pause(), queue.resume(), queue.paused, queue.volume e queue.filters agem sobre o que estiver tocando.
  • queue.stop() encerra todas as faixas; as faixas adicionadas depois voltam a tocar.
  • await queue.join() espera até não haver nada na fila nem tocando.

Uma fila em uma conexão que terminou fica closed e não aceita mais faixas; crie uma nova depois de reconectar, como o trecho de código faz.

Sons do painel de sons

await bot.rest.soundboard.send(vc.channel_id, sound_id=...) toca um som do painel de sons no canal em que o bot está conectado. O próprio Discord o toca na chamada, junto com o que o bot envia; passe source_guild_id para um som de outro servidor. bot.rest.soundboard.list_default() lista os sons do próprio Discord e list(guild_id) os de um servidor, e bot.request_soundboard_sounds(guild_id) os pede ao gateway.

O mesmo áudio em vários servidores

await voice.broadcast(source, connections) toca uma fonte em várias conexões, decodificando-a e codificando-a uma única vez. As conexões que terminam saem do conjunto; as que entram depois não são adicionadas. Ele aceita encoder e stall, "drop" por padrão, para que uma conexão parada não segure as outras.

Codificador

voice.EncoderSettings configura o codificador Opus de uma conexão, por Defaults(encoder=...), connect(encoder=...) ou vc.encoder = ... enquanto conectado:

Campo Padrão Significado
bitrate None bits por segundo, de 500 a 512 000; None segue o bitrate do canal, ou 64 000
application "audio" "audio" para música, "voip" para fala, "lowdelay" para a menor latência
fec True correção de erros antecipada (FEC), que disfarça pacotes perdidos
packet_loss 0.1 a perda esperada, de 0 a 1, para a qual fec se prepara
complexity 10 de 0 a 10; menos usa menos CPU
bandwidth "auto" a banda de áudio mais alta: "narrow", "medium", "wide", "superwide" ou "full"
signal_type "auto" ajusta para "voice" ou "music"

vc.speaking_flags define as flags de fala que o bot anuncia, voice.SpeakingFlags.MICROPHONE por padrão, SOUNDSHARE para áudio compartilhado, PRIORITY para voz prioritária.