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 SFU | Sem SFU | |
|---|---|---|
| Quando | O servidor tem CF_RT_APP_ID configurado | Não tem |
| Como o áudio trafega | Todos publicam no SFU (Cloudflare Realtime) | Malha P2P, cada par com cada par |
client.sfuEnabled | true | false |
Rotas /api/sfu/* | funcionam | respondem 404 |
| Um bot pode tocar áudio | sim | nã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');
}
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:
joinpelo WebSocket. Sem estar numa sala, a política de voz do servidor devolve403 Entre em um canal de vozem qualquer rota/api/sfu/*.POST /api/sfu/sessioncom uma oferta só de recepção: toda seçãom=precisa tera=recvonlyoua=inactive. Oferta comsendonlyna primeira chamada é recusada com400 A conexão inicial deve ser somente de recepção.- Transceiver
sendonly+POST /api/sfu/{sessão}/tracks, comtrackNameno formato<kind>-<sufixo>, ondekindémic,cam,screenousaudio. A seçãom=domidtem de seraudioparamicesaudio, evideopara os outros — o servidor confere, não dá para disfarçar pelo nome. - Anúncio por
mediano WebSocket, com o camposfu: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.{ "type": "media", "mic": true, "cam": false, "screen": false, "deaf": true,"sfu": { "session": "…", "tracks": { "mic": "mic-abc123" } } }
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ósitoUm 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ção | Padrão | O que faz |
|---|---|---|
inputType | 'arbitrary' | Como interpretar a entrada |
volume | 1 | 0 a 2, aplicado antes do encode (só em arbitrary e raw) |
seek | — | Começa no segundo N (vira -ss do ffmpeg) |
bitrate | 96000 | Bitrate 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));
tailSilenceplay() 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:
| Sintoma | Conferir |
|---|---|
VoiceError ao entrar | Quem chamou está numa sala? O bot tem CONNECT? |
403 Entre em um canal de voz | O join não completou. Espere o voiceJoin |
| 404 nas rotas do SFU | client.sfuEnabled é false — servidor em malha, veja o aviso no topo |
400 conexão inicial deve ser somente de recepção | Primeira 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 ouve | O anúncio media com sfu não saiu. Chame conexao.announce() |
O player vai para idle na hora | ffmpeg não está no PATH, ou a fonte não existe. Ouça player.on('error') |
| O bot entra e sai sozinho | Sala cheia (channel.full), ou sala temporária esvaziou e morreu |
| Áudio sai cortado no fim | Aumente 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
kindcam,screenesaudioexistem 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.