Pular para o conteúdo principal

Voz

O concord.js/voice toca áudio de verdade numa sala do Concord, fora do navegador: ffmpeg transcodifica a entrada para Opus e uma stack WebRTC publica a faixa no SFU. A sala ouve como ouviria uma pessoa.

Antes do código, a ressalva que importa mais do que tudo nesta página.

Leia isto primeiro: voz exige SFU ligado​

O Concord funciona em dois modos, e quem decide é o servidor, não o bot:

Com SFUSem SFU
QuandoO servidor tem CF_RT_APP_ID configuradoNão tem
Como o áudio trafegaTodos publicam no SFU (Cloudflare Realtime)Malha P2P, cada par com cada par
client.sfuEnabledtruefalse
Rotas /api/sfu/*funcionamrespondem 404
Um bot pode tocar áudiosimnão, hoje
if (!client.sfuEnabled) {
// Em malha P2P a conexão de voz existe (`mode === 'mesh'`) e o bot aparece na
// sala, mas publicar áudio depende de sinalização par a par com cada pessoa.
console.warn('servidor em malha P2P: o bot entra na sala, mas não publica áudio');
}
Sem SFU, o bot não toca

Em malha P2P, VoiceConnection abre com mode === 'mesh', usa signal e welcome.ice, e o bot aparece no canal — mas não há caminho de publicação servido pelo servidor, e as rotas /api/sfu/* devolvem 404. Se o seu bot de música não faz som, a primeira coisa a conferir é client.sfuEnabled, não o ffmpeg.

A ordem obrigatória​

O servidor impõe uma sequência. Fora dela, cada passo falha de um jeito diferente, e nenhum dos erros diz "você pulou um passo". O concord.js cumpre a ordem por você; vale conhecê-la para depurar:

  1. join pelo WebSocket. Sem estar numa sala, a política de voz do servidor devolve 403 Entre em um canal de voz em qualquer rota /api/sfu/*.
  2. POST /api/sfu/session com uma oferta só de recepção: toda seção m= precisa ter a=recvonly ou a=inactive. Oferta com sendonly na primeira chamada é recusada com 400 A conexão inicial deve ser somente de recepção.
  3. Transceiver sendonly + POST /api/sfu/{sessão}/tracks, com trackName no formato <kind>-<sufixo>, onde kind é mic, cam, screen ou saudio. A seção m= do mid tem de ser audio para mic e saudio, e video para os outros — o servidor confere, não dá para disfarçar pelo nome.
  4. Anúncio por media no WebSocket, com o campo sfu:
    { "type": "media", "mic": true, "cam": false, "screen": false, "deaf": true,
    "sfu": { "session": "…", "tracks": { "mic": "mic-abc123" } } }
    Sem esse anúncio ninguém ouve, mesmo com a faixa publicada e tocando. É o passo que mais some em implementação feita à mão.

Os quatro booleanos do media são obrigatórios; só sfu é opcional. São 4 faixas no máximo por sessão, uma por kind.

Entrando e tocando​

import { createAudioResource } from 'concord.js/voice';

const conexao = await client.voice.join(canalId, { selfDeaf: true });
await conexao.play(createAudioResource('/caminho/musica.mp3'));

Dentro de um comando é mais curto ainda — ctx.joinVoice() entra no canal de quem chamou:

@SlashCommand({
// `play`/`tocar` nunca chegam ao bot com o prefixo `/`: o servidor os consome
// (veja "Nomes reservados pelo servidor" em Comandos).
name: 'toca',
description: 'Toca um áudio na sala de voz',
options: [{ name: 'fonte', description: 'URL ou caminho do arquivo', type: 'rest', required: true }],
permissions: [PermissionFlags.CONNECT, PermissionFlags.SPEAK],
})
async toca(@Ctx() [ctx]: SlashCommandContext, @Opt('fonte') fonte: string) {
// `joinVoice` manda `join`, espera o `joined` e sobe a stack de mídia.
// Lança VoiceError se quem chamou não está em nenhuma sala.
const conexao = await ctx.joinVoice();
await conexao.play(createAudioResource(fonte, { inputType: 'arbitrary' }));
await ctx.react('▶️');
}
selfDeaf: true é o padrão, e é de propósito

Um bot que toca não precisa ouvir. Ficar surdo evita assinar as faixas de todo mundo na sala — menos banda, menos CPU, menos faixa remota para o SFU gerenciar.

Fontes de áudio​

createAudioResource aceita caminho de arquivo, URL, um Readable ou '-' para stdin.

// 'arbitrary' (padrão): passa pelo ffmpeg. Qualquer coisa -> Opus 48 kHz
// estéreo, em quadros de 20 ms.
createAudioResource('https://exemplo.com/faixa.m4a', { inputType: 'arbitrary' });

// 'opus': Ogg/Opus já pronto, não passa pelo ffmpeg.
createAudioResource('./pronto.ogg', { inputType: 'opus' });

// 'raw': s16le 48 kHz estéreo.
createAudioResource(streamPcm, { inputType: 'raw' });
OpçãoPadrãoO que faz
inputType'arbitrary'Como interpretar a entrada
volume10 a 2, aplicado antes do encode (só em arbitrary e raw)
seek—Começa no segundo N (vira -ss do ffmpeg)
bitrate96000Bitrate do Opus
ffmpegArgs—Argumentos extras, no fim da linha de entrada
metadata{}O que você quiser carregar junto — título, quem pediu

O metadata é o lugar de guardar o que a fila precisa:

const recurso = createAudioResource(url, {
metadata: { titulo: 'Cássia Eller — Malandragem', pediu: ctx.authorId },
});

conexao.player.on('finish', (r) => {
console.log(`terminou: ${r.metadata.titulo}`);
});

Com yt-dlp​

O yt-dlp escreve no stdout e o ffmpeg lê de lá. Nada toca o disco:

import { spawn } from 'node:child_process';

const ytdlp = spawn('yt-dlp', ['-f', 'bestaudio', '-o', '-', '--quiet', url], {
stdio: ['ignore', 'pipe', 'ignore'],
});

await conexao.play(createAudioResource(ytdlp.stdout, { inputType: 'arbitrary' }));

O player​

Cada VoiceConnection tem um AudioPlayer:

const player = conexao.player;

player.play(recurso, { mode: 'queue' }); // 'queue' espera o atual acabar
player.play(recurso, { mode: 'now' }); // 'now' interrompe
player.pause();
player.unpause();
player.stop(); // para e drena a cauda de silêncio da faixa atual
player.stop({ force: true }); // descarta a cauda também (corte seco)
player.setVolume(0.6);

Os estados são idle, buffering, playing, paused e autopaused:

player.on('stateChange', (atual, anterior) => console.log(`${anterior} -> ${atual}`));
player.on('start', (r) => console.log('começou', r.metadata.titulo));
// `finish` é "terminou sozinho" — não dispara em `stop()`.
player.on('finish', () => tocarProxima());
player.on('error', (err) => console.error('falha no áudio:', err.message));
tailSilence

play() aceita tailSilence, em segundos (padrão 0,1). É silêncio mandado no fim para o último quadro não ser cortado no caminho. Se o fim das faixas parece engolido, aumente.

Saindo​

Três formas, com efeitos diferentes:

// Para o áudio e continua na sala.
conexao.stop();

// Para o áudio e sai da sala (manda `leave`).
conexao.stop({ disconnect: true });

// Sai da sala e destrói a sessão de mídia.
await conexao.disconnect();

// Derruba tudo SEM mandar `leave`: a vaga na sala expira em 20 s.
conexao.destroy('o processo vai cair');

A diferença entre disconnect() e destroy() é a carência: ao cair sem leave, o servidor segura a vaga por 20 segundos. Isso existe para reconexão rápida — e é por isso que o Client mantém o backoff curto enquanto a queda cabe nessa janela.

Estado da conexão​

conexao.status; // 'joining' | 'connecting' | 'ready' | 'reconnecting' | 'destroyed'
conexao.mode; // 'sfu' | 'mesh'
conexao.sessionId; // id no SFU, null em malha
conexao.publishedTracks; // { mic: 'mic-abc123' }, como foi anunciado
conexao.selfDeaf;

// `ready` é o marco real: dali em diante a sala ouve.
conexao.on('ready', () => console.log('publicado e anunciado'));
conexao.on('reconnecting', ({ attempt, delayMs }) => console.log(`tentativa ${attempt} em ${delayMs}ms`));
conexao.on('memberUpdate', (estado, anterior) => { /* alguém entrou, saiu ou mudou mídia */ });
conexao.on('destroyed', ({ reason }) => console.log('fim:', reason));

await conexao.waitFor('ready', 15_000);

Quando não sai som​

Na ordem em que vale conferir:

SintomaConferir
VoiceError ao entrarQuem chamou está numa sala? O bot tem CONNECT?
403 Entre em um canal de vozO join não completou. Espere o voiceJoin
404 nas rotas do SFUclient.sfuEnabled é false — servidor em malha, veja o aviso no topo
400 conexão inicial deve ser somente de recepçãoPrimeira oferta com sendonly. É bug de implementação própria; o SDK não faz isso
Status fica em ready, player em playing, e ninguém ouveO anúncio media com sfu não saiu. Chame conexao.announce()
O player vai para idle na horaffmpeg não está no PATH, ou a fonte não existe. Ouça player.on('error')
O bot entra e sai sozinhoSala cheia (channel.full), ou sala temporária esvaziou e morreu
Áudio sai cortado no fimAumente o tailSilence

O que ainda não existe​

Para não descobrir no meio do caminho:

  • Receber áudio. O SDK publica; não há API para gravar ou transcrever o que a sala fala.
  • Câmera e tela. Os kind cam, screen e saudio existem no protocolo e a API de faixas os aceita, mas não há helper pronto para publicar vídeo.
  • Tocar em malha P2P. Veja o aviso no topo.