Ao tentar criar um bot de música para o Discord em Go, aprendi um pouco mais sobre alguns conceitos:
- Subprocessos
- Áudio Opus
- Formato Ogg
Vou ensinar o que aprendi nesse post. Ressalto que esse post é para fins educacionais e há formas mais simples de atingir o objetivo do bot de música. O intuito aqui é ensinar um pouco do que aprendi durante minha primeira tentativa.
Visão geral
Em alto nível, o bot vai funcionar com uma pipeline. Usaremos o yt-dlp para extrair o áudio do YouTube (o programa aceita várias fontes, mas aqui iremos focar no YouTube) e o ffmpeg para garantir um encoding padrão e consistente do áudio. Por fim, vamos precisar extrair frames Opus do áudio providenciado pelo ffmpeg para enviar ao Discord, mas isso será feito diretamente no código Go. Dado isso, certifique-se que ambos o yt-dlp e o ffmpeg estejam instalados para o funcionamento correto.
Baixando o áudio
O yt-dlp, por padrão, baixa vídeos por completo do YouTube. No nosso caso, queremos apenas o áudio. Isso pode ser atingido com a flag --extract-audio
(um alias mais curto dessa flag é -x
):
yt-dlp --extract-audio "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
Como podemos executar isso em código Go? É simples, usando o pacote exec da biblioteca padrão, conseguimos facilmente executar o mesmo comando. O pacote é responsável por configurar e iniciar subprocessos, que executam programas externos.
dlp := exec.Command("yt-dlp", "--extract-audio", "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
dlp.Run()
Com esse trecho, iniciamos um subprocesso que executa o yt-dlp. O uso do método cmd.Run inicia o subprocesso, e espera sua finalização na goroutine atual.
É possível que o resultado do yt-dlp sendo chamado manualmente gere um arquivo
.opus
válido. Isso acontece porque o yt-dlp já chama o ffmpeg nesse caso. Isso não será útil para nós porque ao usar pipes (visto posteriormente), o áudio não é gerado nesse formato
Codificando o áudio
A documentação da API do Discord menciona que o áudio deve ser enviado no formato Opus a um sample rate de 48kHz e com dois canais (stereo). A biblioteca que iremos usar mais a frente, discordgo, lida com todos os outros detalhes técnicos do envio, como criptografia end-to-end e gerenciamento do estado. Dito isso, ainda cabe a nós o envio apropriado dos bytes de áudio.
Tendo isso em mente, procurei a ferramenta mais famosa para processamento multimídia, o ffmpeg. Podemos gerar um arquivo opus da seguinte forma:
ffmpeg -i audio.mp3 -f opus -frame_duration 20 -ar 48000 -ac 2 audio.opus
-
-i audio.mp3
: arquivo de entrada para a codificação -
-f opus
: seleciona o formato Opus para saída -
-frame_duration
: define a duração de frames Opus a 20 milissegundos -
-ar 48000
: define o sample rate como 48kHz -
ac 2
: define a saída em modo stereo -
audio.opus
: arquivo de saída
De forma análoga ao yt-dlp, podemos executar o comando em Go:
ffmpeg := exec.Command("ffmpeg", "-i", "audio.mp3", "-f", "opus", "-frame_duration", "20", "-ar", "48000", "-ac", "2", "audio.opus")
ffmpeg.Run()
Agora, podemos executar os dois comandos e obter um áudio apropriado para o Discord. Porém, ainda temos dois problemas. O primeiro é que tudo que fizemos foi executar os dois comandos a parte. Dessa forma, teriamos um trabalho manual de baixar a música, codifica-la e salva-lá em um arquivo que apenas então seria enviado ao Discord. O que buscamos aqui é criar uma stream de áudio, sem precisar salvar arquivos em cada etapa.
O segundo problema é que o Discord requer que enviemos Opus frames, um por um. Um arquivo .opus
é, na verdade, um arquivo com metadados (normalmente usando o formato/container Ogg), que encapsulam os frames. Os frames são a unidade mais básica de áudio do formato. Por essa razão, não podemos simplesmente enviar os bytes do arquivo final para a API do Discord.
Gerando uma stream de dados
Para gerar uma stream de dados, evitando que precisemos salvar o arquivo a cada etapa do processo, vamos usar o de pipes, normalmente aplicado no shell de sistemas Unix-like. No fim dessa etapa, vamos ter uma cadeia de ações que baixa o áudio do YouTube, transmite-o diretamente para o ffmpeg e então escreve seus bytes na saída padrão. O código será equivalente ao seguinte:
yt-dlp --extract-audio "https://www.youtube.com/watch?v=dQw4w9WgXcQ" -o - | \
ffmpeg -i - -f opus -frame_duration 20 -ar 48000 -ac 2 -
Atenção: não execute o comando diretamente no seu terminal, porque ele vai gerar uma saída ilegível e pode congelar seu terminal.
No comando acima, o uso de -
indica entradas ou saídas padrão (stdin/stdout).
Esse processo permita que o ffmpeg leia "diratemente" do yt-dlp e então jogue sua saída para o stdout. Vamos usar isso no código Go para obter a saída sem necessidade de chamadas adicionais de leitura de arquivo.
Primeiramente, vamos criar uma pipe para a saída do yt-dlp. Isso pode ser feito com o método StdoutPipe.
dlp := exec.Command(
"yt-dlp",
"--extract-audio",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"-o", "-",
)
pipe, err := dlp.StdoutPipe()
if err != nil {
return err
}
O código estabelece que a saída padrão do yt-dlp irá conter os bytes do arquivo de áudio extraído. Essa saída está pode ser acessada pela variável pipe, a qual é um io.ReadCloser. Ele implementa a interface padrão de leitura do Go. Não vamos nos preocupar em fechar essa pipe pois isso será tratado depois pelo próprio pacote, na chamada de cmd.Wait.
Como discutido, o ffmpeg vai receber seu arquivo de entrada a partir da entrada padrão stdin. Dessa forma, vamos associar a pipe à entrada padrão do ffmpeg.
// ... código anterior
ffmpeg := exec.Command(
"ffmpeg",
"-i", "-",
"-f", "opus",
"-frame_duration", "20",
"-ar", "48000",
"-ac", "2",
"-",
)
ffmpeg.Stdin = pipe
audioPipe, err := ffmpeg.StdoutPipe()
if err != nil {
return err
}
Nós também fazemos um pipe com a saída do ffmpeg. Isso nos permite ler a saída diretamente no código. Agora, já podemos usufruir do resultado do ffmpeg. Para isso, basta iniciar os dois comandos.
// exemplo: usando os processos para exibir a hash sha256 do áudio
//
// ... código anterior
if err := dlp.Start(); err != nil {
return err
}
if err := ffmpeg.Start(); err != nil {
return err
}
data, _ := io.ReadAll(audioPipe) // ignorando tratativa de erro
fmt.Println(sha256.Sum256(data)) // exibe os bytes puros da hash
// ignorando erros
// esperamos os processos finalizar para garantir liberação de recursos
_ = dlp.Wait()
_ = ffmpeg.Wait()
Extraindo os frames Opus
Antes de enviar o áudio para o Discord, agora precisamos extrair os frames de áudio Opus do arquivo. Isso acontece porque estamos gerando arquivos .opus
, com container Ogg. Primeiro, uma breve explicação de codecs e containers:
Um codec (compressor/descompressor) é responsável pela codificação ou decodificação de dados multimídia. Eles definem com a mídia deve ser representada, buscando objetivos como ocupar menos espaço, ou maior eficiência de transmissão. Um container é um formato que permite várias streams, podendo elas serem áudio, vídeo ou até legendas, em um único arquivo. O container define formatos para representar metadados, e como representar os dados "crus" do codec sendo contido.
Nesse contexto, um arquivo .opus
normalmente é um arquivo que utiliza o container Ogg para armazenar áudio com o codec Opus. O Ogg é responsável por definir metadados que podem armazenar tags, comentários e também onde a stream Opus se encontra.
O formato Ogg
O Ogg organiza dados em pages (páginas). Cada page contém packets (pacotes) que são unidades menores que contém os dados brutos.
No caso de áudio Opus, as duas primeiras pages contém metadados, os quais identificam o arquivo como uma stream Opus, e dados de tags e comentários. A terceira page contém, os packets de áudio puro, os frames Opus que queremos. Cada packet é um frame.
Extraindo os frames Opus em Go
Tendo essa breve introdução aos formatos em mente, vamos extrair os frames em Go. Para isso, vamos usar um decoder do formato Ogg. O pacote é github.com/jonas747/ogg.
A seguir, um exemplo em que exibimos o tamanho de cada frame:
// assumindo código preexistente com audioPipe saindo do ffmpeg
pageDecoder := ogg.NewDecoder(audioPipe)
// pulamos as duas primeiras páginas
pageDecoder.Decode()
pageDecoder.Decode()
packetDecoder := ogg.NewPacketDecoder(pageDecoder)
for {
packet, _, err := packetDecoder.Decode()
if err != nil {
break
}
fmt.Println("Tamanho do frame:", len(packet))
}
Isso é possível pois fizemos uma pipe da saída do ffmpeg. Ela implementa io.Reader, o qual deve ser usado para o decoder.
Enviando o áudio para o Discord
Agora que já conseguimos extrair o áudio, vamos exemplificar como fazer um bot musical do Discord. Ressalto que, como o foco dessa postagem não é sobre o bot, e sim sobre as tecnologias e o aprendizado. Portanto, a lógica do bot será simples e não interativa, focando apenas em tocar a música.
Com tudo que construimos, podemos gerar o código final. Ele consiste em usar o discordgo para inicializar um bot, conectar a um canal de voz e, então, tocar uma música utilizando os subprocessos.
package main
import (
"fmt"
"log/slog"
"os"
"os/exec"
"github.com/bwmarrin/discordgo"
"github.com/jonas747/ogg"
)
// atualize com seus valores
const (
token = "seuToken"
guildID = "seuGuildID"
channelID = "seuChannelID"
)
func main() {
if err := mainWithError(); err != nil {
slog.Error(err.Error())
}
}
func mainWithError() error {
session, err := discordgo.New("Bot " + token)
if err != nil {
return fmt.Errorf("failed to create bot session: %w", err)
}
slog.Info("Opening bot session")
if err := session.Open(); err != nil {
return fmt.Errorf("failed to open bot session: %w", err)
}
defer session.Close()
// entrar no canal de voz
voice, err := session.ChannelVoiceJoin(guildID, channelID, false, false)
if err != nil {
return fmt.Errorf("failed to join voice channel: %w", err)
}
// configurando fonte de áudio
dlp := exec.Command(
"yt-dlp",
"--extract-audio", "https://youtu.be/jDHmg8Mb9wo",
"-o", "-",
)
dlpPipe, err := dlp.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to get yt-dlp pipe: %w", err)
}
dlp.Stderr = os.Stderr // obter saída informativa do yt-dlp
ffmpeg := exec.Command(
"ffmpeg",
"-i", "-",
"-f", "opus",
"-frame_duration", "20",
"-ar", "48000",
"-ac", "2",
"-",
)
ffmpegPipe, err := ffmpeg.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to get ffmpeg pipe: %w", err)
}
ffmpeg.Stdin = dlpPipe // associa saída do yt-dlp à entrada do ffmpeg
ffmpeg.Stderr = os.Stderr
if err := dlp.Start(); err != nil {
return err
}
if err := ffmpeg.Start(); err != nil {
return err
}
pageDecoder := ogg.NewDecoder(ffmpegPipe)
pageDecoder.Decode()
pageDecoder.Decode()
// sinaliza ao Discord que estamos enviando áudio
voice.Speaking(true)
packetDecoder := ogg.NewPacketDecoder(pageDecoder)
for {
packet, _, err := packetDecoder.Decode()
if err != nil {
// esperamos que o único erro seja io.EOF
return err
}
// enviando o áudio para o Discord
voice.OpusSend <- packet
}
}
Finalizando
Assim, podemos criar um bot simples de música. Para projetos reais, eu recomendaria outras abordagens, evitando o overhead e complexidade de subprocessos. Um projeto em potencial seria esse. Ainda seria necessário o uso do ffmpeg, para a geração de Opus, mas a coordenação de um único subprocesso é consideravelmente mais fácil.
Comente se aprendeu algo novo, se sobraram dúvidas, ou se encontrou algum erro. Eu não sou da área multimídia, e tudo que aprendi foi com esse projeto.
Top comments (0)