Ligação com voz clonada da ElevenLabs no Node.js
Olá meus Unicórnios! 🦄✨
Já tinha a voz clonada funcionando. Digitava uma frase, clicava, e saía o áudio com aquele timbre, bonitinho, no navegador. 😍 Aí veio a parte que eu achei que seria simples: colocar essa voz dentro de uma ligação telefônica.
Sabe quando você acha que vai ser meia hora de trabalho? 😅 Pois é.
O que eu imaginava: pega o arquivo de áudio, joga na chamada, pronto. O que aconteceu de verdade: o primeiro áudio não tocou, o segundo tocou com um estalo horrível no começo, o terceiro tocou depois de a pessoa já ter desligado, e o quarto continuou falando sozinho por vários segundos depois de eu mandar parar. 🙃
Este artigo é sobre esses quatro problemas, que no fundo são três perguntas: em que formato o áudio precisa estar, em que ritmo ele precisa sair, e o que a linha escuta enquanto ele não está pronto.
📞 O telefone é bem mais velho que você, e quem se adapta é o seu código
Primeira coisa a entender, e ela explica quase tudo que vem depois: a telefonia não fala MP3. Nem WAV bonito de 44 kHz. Nem OGG.
A rede telefônica fala G.711 mu-law a 8 kHz, mono. Você vai ver esse formato chamado de PCMU, de ulaw ou de mu-law, e é tudo a mesma coisa. Oito mil amostras por segundo, um byte por amostra, um canal só.
Isso é pouquíssimo dado comparado com música. E é de propósito: é o suficiente para a voz humana ficar inteligível, e foi decidido quando ligação era coisa de cabo de cobre. A rede continua assim até hoje.
Então o primeiro impulso, o de converter o MP3 que a ElevenLabs devolve, é o caminho errado. Ele funciona, mas exige um ffmpeg no meio, que custa tempo e é mais uma peça para quebrar. A ElevenLabs entrega no formato da telefonia direto, e isso é só um parâmetro na URL:
const endereco = API + "/text-to-speech/" + encodeURIComponent(VOZ)
+ "?output_format=ulaw_8000";
É esse ?output_format=ulaw_8000 que muda tudo. 🎯 Com ele, o que chega na sua mão já são os bytes que a linha telefônica quer, sem intermediário nenhum. Sem ele, chega MP3 e você tem um problema de conversão que não precisava ter.
A função inteira que pede o áudio fica assim:
// Pede o audio ja no formato da telefonia. Sem o output_format a ElevenLabs
// devolve MP3, que nao entra numa ligacao sem ser convertido antes.
async function gerarAudio(texto) {
const endereco = API + "/text-to-speech/" + encodeURIComponent(VOZ)
+ "?output_format=ulaw_8000";
let resposta;
try {
resposta = await fetch(endereco, {
method: "POST",
// A ElevenLabs autentica por header proprio, e nao por Bearer.
headers: { "xi-api-key": CHAVE, "Content-Type": "application/json" },
body: JSON.stringify({ text: texto, model_id: "eleven_multilingual_v2" }),
});
} catch (erro) {
throw new Error("Nao consegui falar com a ElevenLabs: " + erro.message);
}
if (!resposta.ok) {
const detalhe = await resposta.text();
throw new Error("A ElevenLabs recusou o pedido (" + resposta.status + "): " + detalhe);
}
return Buffer.from(await resposta.arrayBuffer());
}
Repare no comentário do header: a ElevenLabs autentica com xi-api-key, e não com o Authorization: Bearer ao qual a gente está viciada. Eu perdi um tempo bobo nisso, mandando Bearer e levando 401 achando que a chave estava errada. 🤦♀️
E repare também que a chave e o id da voz vêm do ambiente, nunca escritos no arquivo:
const CHAVE = process.env.ELEVENLABS_API_KEY;
const VOZ = process.env.ELEVENLABS_VOICE_ID;
🔢 A conta dos 160 bytes
Com o áudio na mão, veio a segunda surpresa. Eu mandei o buffer inteiro de uma vez para a ligação, e o resultado foi estranho: a voz saía, mas com um atraso esquisito e sem nenhuma relação com o que eu esperava.
O motivo: a telefonia não trabalha com arquivos, trabalha com pacotinhos em ritmo constante. E o tamanho do pacote não é escolha sua, é uma conta do formato.
São 8000 amostras por segundo, um byte cada, e o pacote padrão carrega 20 ms de áudio. Então:
8000 amostras/s x 0,020 s = 160 bytes por pacote
1000 ms / 20 ms = 50 pacotes por segundo
160 bytes, 50 vezes por segundo. Esse é o relógio da ligação, e o seu código precisa bater nele.
Por isso despejar tudo de uma vez não adianta: a operadora vai bufferizar e tocar no ritmo dela de qualquer forma. Você não ganha velocidade nenhuma, só perde o controle sobre o que já foi entregue (e isso vai doer lá na frente, quando a pessoa interromper). Enviar no ritmo certo é:
// Corta o audio em pacotes de 20 ms e envia um a cada 20 ms.
// Enviar tudo de uma vez nao adianta: a operadora bufferiza e a fala
// atrasa em vez de acelerar.
async function tocarNaLigacao(socket, audio, streamId) {
for (let inicio = 0; inicio < audio.length; inicio += BYTES_POR_PACOTE) {
if (socket.readyState !== 1) return;
let pacote = audio.subarray(inicio, inicio + BYTES_POR_PACOTE);
// O ultimo pedaco quase nunca fecha 160 bytes: completa com silencio.
if (pacote.length < BYTES_POR_PACOTE) {
pacote = Buffer.concat([
pacote,
Buffer.alloc(BYTES_POR_PACOTE - pacote.length, SILENCIO),
]);
}
socket.send(JSON.stringify({
event: "media",
stream_id: streamId,
media: { payload: pacote.toString("base64") },
}));
await esperar(MILISSEGUNDOS_POR_PACOTE);
}
}
Aquele if do último pedaço não é preciosismo: o áudio quase nunca tem um tamanho múltiplo de 160, então o último pacote sai curto. Um pacote curto é um pacote malformado para a operadora, e o que eu ouvia era um clique no fim de cada frase. Completar com silêncio resolve.
🔇 O silêncio é 0xFF, e essa foi a mais cruel
Agora a armadilha que me custou mais tempo, e que eu acho linda de tão contraintuitiva. 😳
Quando eu precisei preencher com silêncio (no último pacote, e depois na espera), fiz o que qualquer pessoa faria: enchi de zeros. Buffer.alloc(160, 0). Silêncio é ausência de som, ausência de som é zero. Óbvio.
O resultado foi um estalo. Alto. Em todas as pausas.
É que mu-law não é PCM linear. Ele é uma codificação logarítmica, e a tabela dele não coloca o zero onde a gente espera. Rodando a conversão de mu-law para PCM nos dois bytes:
0xFF -> 0 <- silencio de verdade
0x00 -> -32124 <- quase o fundo da escala negativa
Isso mesmo: o byte 0x00 é praticamente o volume máximo negativo. 🤯 Encher uma pausa de zeros não produz silêncio, produz um estouro. Quem faz silêncio em mu-law é o 0xFF.
// Silencio em mu-law e 0xFF, e nao 0x00. Preencher com zeros da um estalo.
const SILENCIO = 0xff;
É o tipo de coisa que nenhuma documentação de API vai te contar, porque não é da API: é do formato, e o formato é bem mais velho que qualquer uma delas. Se você só for ler um pedaço deste artigo, leia este. 🙏
⏳ O tempo de gerar não cabe dentro da ligação
E chegamos ao problema que muda o desenho do código todo.
Gerar o áudio leva tempo. Não é instantâneo: a ElevenLabs precisa receber o texto, sintetizar e devolver os bytes. Dependendo do tamanho da frase e do modelo, isso vai de alguns décimos de segundo a alguns segundos.
Só que a ligação já está acontecendo. A pessoa já atendeu. Ela já disse "alô". E do lado de cá o seu código está esperando um fetch resolver.
Nesse intervalo, o que ela ouve? Nada. Absolutamente nada. Não é silêncio confortável de conversa, é linha morta, aquele vazio que faz todo mundo falar "alô? alô?" e desligar.
Eu perdi várias chamadas de teste assim, achando que era problema de conexão. Não era: era o tempo de geração acontecendo dentro de uma linha aberta e muda.
A solução é simples e meio óbvia depois que você vê: enquanto o áudio não fica pronto, mande silêncio de verdade, no mesmo ritmo de 20 ms. Silêncio enviado é diferente de nada enviado, porque mantém a linha viva:
// Enquanto o audio nao fica pronto, a linha nao pode ficar muda: a pessoa
// acha que caiu e desliga. Manda silencio de verdade ate o audio chegar.
function preencherSilencio(socket, streamId) {
const pacote = Buffer.alloc(BYTES_POR_PACOTE, SILENCIO).toString("base64");
const relogio = setInterval(function () {
if (socket.readyState !== 1) return;
socket.send(JSON.stringify({
event: "media",
stream_id: streamId,
media: { payload: pacote },
}));
}, MILISSEGUNDOS_POR_PACOTE);
return function parar() { clearInterval(relogio); };
}
Repare que ela devolve a função de parar. Isso não é elegância de sintaxe, é o que evita o bug seguinte: se você esquecer de parar o relógio do silêncio antes de começar a falar, os dois passam a mandar pacotes ao mesmo tempo, a 100 por segundo em vez de 50, e a voz sai picotada, misturada com o silêncio. Parece problema de rede e não é.
No fluxo, a ordem é: começou a ligação, liga o silêncio, pede o áudio, para o silêncio, toca o áudio.
pararSilencio = preencherSilencio(socket, streamId);
const comecou = Date.now();
let audio;
try {
audio = await gerarAudio("Oi! Tudo bem? Estou ligando so para confirmar o seu horario.");
} catch (erro) {
// Sem audio nao ha ligacao: para o silencio e desliga, em vez de
// deixar a pessoa ouvindo nada para sempre.
console.error("Falhou a geracao do audio: " + erro.message);
if (pararSilencio) pararSilencio();
socket.close();
return;
}
const demorou = Date.now() - comecou;
const duracao = Math.round((audio.length / AMOSTRAS_POR_SEGUNDO) * 1000);
console.log("audio pronto em " + demorou + " ms, com " + duracao + " ms de fala");
pararSilencio();
await tocarNaLigacao(socket, audio, streamId);
Aquele catch merece atenção, porque é o caso que eu não tinha tratado e que dá o pior resultado possível. Se a geração falha e você não faz nada, o relógio do silêncio continua rodando para sempre: a pessoa fica numa ligação aberta, ouvindo silêncio perfeito, esperando alguém falar. Para sempre. 😬
Por isso o catch faz duas coisas concretas, e não só um console.error: para o silêncio e derruba a linha. Uma ligação que cai é ruim; uma ligação muda que nunca cai é pior.
✋ Parar de mandar não é parar de falar
O último problema é o mais engraçado, porque ele inverte a intuição. 😄
A pessoa interrompe no meio da frase. Você quer que a voz pare. Então você para de mandar pacotes, lógico.
E a voz continua falando. Por vários segundos.
É que aqueles pacotes que você já mandou não foram tocados ainda: estão numa fila do lado da operadora, esperando a vez. Parar de alimentar a fila não esvazia a fila. Você parou de escrever, mas o que já estava escrito continua saindo.
Para descartar de verdade, existe um frame próprio:
// Descarta o que ja saiu daqui mas ainda nao foi tocado. Sem isto, a voz
// continua falando por segundos depois de a pessoa interromper.
function descartarAudioPendente(socket, streamId) {
if (socket.readyState !== 1) return;
socket.send(JSON.stringify({ event: "clear", stream_id: streamId }));
}
Três linhas. E são elas que separam uma voz que parece atenta de uma voz que atropela quem está falando. Sem elas, o efeito é aquele de alguém que não te escuta: você fala, a outra pessoa continua o discurso dela, e só depois percebe. É o detalhe que faz a ligação inteira parecer mal feita.
E é aqui que fica claro por que mandar tudo de uma vez era má ideia lá atrás: quanto mais coisa você despejou na fila, mais longo fica o rabo de áudio que precisa ser descartado. Mandando no ritmo de 20 ms, a fila fica curtinha e o clear tem pouco o que limpar.
🧩 Juntando: a ordem dos eventos
O fluxo inteiro, do jeito que ele acontece:
a pessoa atende
|
chega o evento "start" -> guarda o stream_id
|
liga o silencio (50 pacotes 0xFF por segundo)
|
pede o audio a ElevenLabs ...... demora
|
para o silencio
|
toca o audio (160 bytes a cada 20 ms)
|
se a pessoa interromper -> manda "clear"
O stream_id que chega no evento start precisa ser guardado, porque ele vai em todo pacote que você manda de volta. Pacote sem o identificador certo simplesmente não toca, e não dá erro nenhum: só não sai som. Foi outro meio expediente da minha vida. 🙃
E as constantes, todas derivadas da conta do formato:
// A telefonia fala G.711 mu-law a 8 kHz, em pacotes de 20 ms.
// 8000 amostras por segundo x 0,020 s = 160 bytes por pacote.
const AMOSTRAS_POR_SEGUNDO = 8000;
const MILISSEGUNDOS_POR_PACOTE = 20;
const BYTES_POR_PACOTE = (AMOSTRAS_POR_SEGUNDO * MILISSEGUNDOS_POR_PACOTE) / 1000;
// Silencio em mu-law e 0xFF, e nao 0x00. Preencher com zeros da um estalo.
const SILENCIO = 0xff;
Deixei a conta escrita em vez de só pôr 160 de propósito. Quando alguém quiser mexer (usar pacotes de 30 ms, por exemplo), o número se ajusta sozinho e a relação com o formato continua visível no código. Um 160 solto é um número mágico que ninguém sabe de onde veio seis meses depois.
📊 O que a ligação mostrou
Rodando o fluxo com uma frase curta, os números batem exatamente com a conta:
audio pronto em 947 ms, com 2000 ms de fala
terminei de falar
pacotes recebidos: 130 (silencio: 30, fala: 100)
todos com 160 bytes: sim
Vale ler essa saída com calma, porque ela conta a história inteira:
- 947 ms para gerar: quase um segundo inteiro de linha aberta antes da primeira palavra. É esse buraco que o silêncio preenche.
- 30 pacotes de silêncio, que a 20 ms cada dão 600 ms de linha viva durante a espera.
- 100 pacotes de fala para 2000 ms de áudio, que é exatamente 2000 dividido por 20. A conta fecha.
- Todos com 160 bytes, inclusive o último, graças ao preenchimento.
E o caminho de erro, que é o que eu mais queria ver funcionando: pedindo um formato que a API recusa, o resultado é limpo em vez de uma ligação fantasma:
Falhou a geracao do audio: A ElevenLabs recusou o pedido (400): {"detail": "formato nao suportado"}
pacotes recebidos: 1 (silencio: 1, fala: 0)
Um pacote de silêncio, a falha, e a linha cai. Sem áudio pela metade, sem ninguém esperando para sempre. É assim que a falha tem de parecer. 👌
Quatro coisas, e todas elas vêm do fato de a telefonia ser bem mais velha que a gente: peça ulaw_8000 e não precisa converter nada; mande 160 bytes a cada 20 ms, porque esse é o relógio da linha; silêncio é 0xFF, e mandá-lo durante a geração é o que impede a pessoa de desligar; e parar de mandar não para a voz, para isso existe o clear. Nenhuma delas é difícil, e todas são invisíveis até você tropeçar. 😄
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Qual formato de áudio a telefonia aceita?
?output_format=ulaw_8000 na URL. Sem esse parâmetro ela devolve MP3, que não entra numa ligação sem ser convertido antes.Por que não posso mandar o áudio todo de uma vez?
Por que 160 bytes por pacote?
O que fazer enquanto o áudio ainda está sendo gerado?
Por que o silêncio em mu-law é 0xFF e não 0x00?
0xFF é que decodifica para amplitude zero. O 0x00 decodifica para -32124, praticamente o fundo da escala negativa: preencher a pausa com zeros produz um estalo bem audível em vez de silêncio. É o erro clássico de quem vem do PCM linear, onde zero é silêncio mesmo.Como parar a voz quando a pessoa interrompe?
clear para a operadora, que descarta o que já saiu daqui mas ainda não foi tocado. Sem isso, você para de enviar e a voz continua falando por segundos, porque o buffer do outro lado ainda está cheio. Parar de mandar não é o mesmo que parar de falar.Leia também
API Mágica: CEP e Pix de graça, sem cartão
Lancei a API Mágica: CEP, QR Code Pix, geradores e mais, de graça. Veja como consultar e gerar com Node.js em poucas linhas.
Resend: enviando e recebendo e-mails com Node.js
Tutorial da Resend com Node.js: criar a chave, enviar com fetch, verificar o domínio e receber e-mails por webhook conferindo a assinatura.
Node.js: testando scripts de scraping no ScrapingCourse
O ScrapingCourse é um site feito para treinar scraping. Cinco desafios dele em Node.js, e a armadilha real que cada um esconde.