Pular para o conteúdo
Node.js

Ligação com voz clonada da ElevenLabs no Node.js

Ilustração colorida de um unicórnio de crina arco-íris falando ao microfone de um telefone dourado antigo, com a voz saindo como uma fita de ondas que vira uma fileira de pacotes quadrados coloridos até um segundo telefone, ao lado de um castelo com velas flutuantes e uma coruja segurando uma ampulheta

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?
G.711 mu-law a 8 kHz, mono, também chamado de PCMU. É o formato do telefone desde sempre, e a ElevenLabs entrega ele direto se você pedir ?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?
Porque a operadora bufferiza. Despejar os bytes de uma vez não faz a voz sair mais rápido: faz ela sair no ritmo dela de qualquer jeito, e você perde o controle sobre o que já foi entregue. Corte em pacotes de 160 bytes e mande um a cada 20 ms, que é a cadência real da linha.
Por que 160 bytes por pacote?
É a conta do formato: 8000 amostras por segundo, um byte por amostra em mu-law, e 20 ms de áudio por pacote. 8000 vezes 0,020 dá 160 bytes. São 50 pacotes por segundo, o tamanho padrão da telefonia.
O que fazer enquanto o áudio ainda está sendo gerado?
Mandar silêncio de verdade, no mesmo ritmo de 20 ms. A geração leva de alguns décimos a alguns segundos, e nesse intervalo a linha não pode ficar sem pacote nenhum: quem está do outro lado acha que a ligação caiu e desliga antes de você falar a primeira palavra.
Por que o silêncio em mu-law é 0xFF e não 0x00?
Porque em mu-law o byte 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?
Mandando um frame 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