ElevenLabs: gerando samples de voz com Node.js
Olá meus Unicórnios! 🦄✨
Sabe quando você precisa de uma voz gravada e a primeira ideia é abrir o microfone e gravar você mesma? 😅 Pois é. O problema aparece na décima versão do texto, quando alguém troca uma palavra e você tem que regravar tudo de novo, no mesmo tom, com o mesmo ambiente, sem o cachorro latindo no fundo.
Foi por aí que eu cheguei na ElevenLabs. A ideia é simples: você manda um texto e ela devolve o áudio de alguém falando aquilo. E o mais importante para quem programa: ela devolve isso por uma requisição HTTP só, com os bytes do áudio no corpo da resposta. Sem SDK, sem fila, sem webhook.
Este artigo é sobre gerar samples de áudio a partir de texto, do zero, com Node.js puro: escolher a voz, entender os parâmetros que mudam o resultado, escolher o formato e gravar o arquivo em disco. 🎙️
🔑 A chave, e o header que quase todo mundo erra
Antes de qualquer coisa, você precisa de uma chave de API, que sai do painel da ElevenLabs, na área de perfil. E ela nunca vai escrita dentro do arquivo: ela entra por variável de ambiente, e o código só lê de lá.
No Windows, no PowerShell:
$env:ELEVENLABS_API_KEY = "a_sua_chave_aqui"
No Linux ou no Mac:
export ELEVENLABS_API_KEY="a_sua_chave_aqui"
Uma variável definida assim vale só para aquela janela do terminal. Fechou, sumiu, e o script volta a reclamar que não achou a chave: é para ser assim mesmo.
Agora o detalhe que come o tempo de quem está começando. A ElevenLabs não usa o Authorization: Bearer que quase toda API usa hoje. Ela tem um header próprio:
const resposta = await fetch(url, {
method: "POST",
// A ElevenLabs autentica por um header proprio. Nao e Bearer: mandar
// "Authorization: Bearer ..." devolve 401 sem explicar o motivo.
headers: {
"xi-api-key": CHAVE,
"Content-Type": "application/json",
},
body: JSON.stringify(corpo),
});
Repare no detalhe cruel: se você mandar a chave certa no header errado, a resposta é um 401 seco. Ele não diz "você usou o header errado", diz só que não autorizou. É exatamente o tipo de erro que faz a gente conferir a chave cinco vezes, gerar uma chave nova, e o problema estar na linha de cima. 🙄
🗂️ Listando as vozes: a armadilha das 10 primeiras
Para gerar áudio você precisa de um voice_id, que é o código da voz que vai falar. A conta já vem com várias vozes prontas, e é a listagem que diz quais são.
Aqui tem uma pegadinha dupla. A primeira: a listagem e a geração estão em versões diferentes da API. As vozes já migraram para a v2, e a fala continua na v1. Não é erro de digitação meu, e trocar uma pela outra devolve 404.
A segunda é mais silenciosa, e essa dói:
// Listar as vozes da conta. O voice_id e o que voce precisa guardar.
async function listarVozes() {
// ATENCAO ao page_size: sem ele a API devolve so as 10 primeiras vozes, e
// devolve com HTTP 200. A sua voz pode existir e nao aparecer na lista, sem
// erro nenhum para te avisar. O maximo permitido e 100.
const resposta = await fetch(BASE + "/v2/voices?page_size=100", {
headers: { "xi-api-key": CHAVE },
});
Isso mesmo: sem page_size, vêm só 10 vozes. 🤯 E vêm com HTTP 200, lista bonita, nenhum aviso. Se a sua conta tem quinze vozes, cinco simplesmente não existem para o seu código, e você vai jurar que a API perdeu alguma. O máximo permitido é 100, e a resposta ainda traz um campo has_more para você saber se sobrou coisa.
🎛️ Os parâmetros que mudam o resultado
Este é o coração da requisição, e é onde vale gastar um tempo entendendo, porque são esses números que separam um áudio robótico de um áudio que engana:
// Gera o audio e devolve os bytes dele.
async function gerarAudio(vozId, texto, opcoes) {
const config = opcoes || {};
const modelo = config.modelo || "eleven_multilingual_v2";
const formato = config.formato || "mp3_44100_128";
const corpo = {
text: texto,
model_id: modelo,
voice_settings: {
// 0 = mais emocao e mais variacao entre uma geracao e outra.
// 1 = mais monotono, mas igual toda vez que voce rodar.
stability: config.estabilidade === undefined ? 0.5 : config.estabilidade,
// O quanto colar na voz original. Subir demais traz junto o chiado
// e o eco da gravacao de origem, entao 1 raramente e o melhor valor.
similarity_boost: config.similaridade === undefined ? 0.75 : config.similaridade,
},
};
Vamos aos dois botões giratórios, que são os que realmente importam.
A estabilidade (stability) controla o quanto a voz se permite variar. Perto de 0, ela fica mais emotiva, com mais entonação, e sai diferente a cada vez que você roda com o mesmo texto. Perto de 1, fica mais contida e monótona, mas previsível. Se você precisa que dez frases de um mesmo menu de atendimento tenham o mesmo tom, você quer o lado alto. Se quer uma narração viva, quer o lado baixo.
A similaridade (similarity_boost) diz o quanto grudar na voz original. Parece que quanto mais, melhor, mas é armadilha: subir para 1 traz junto o chiado e o eco da gravação de origem, porque o modelo passa a reproduzir fielmente até o que era defeito. Por isso o padrão é 0,75 e não 1.
E tem um terceiro campo, o language_code, com um comportamento que merece atenção:
// ATENCAO: o modelo que NAO entende language_code simplesmente IGNORA o
// campo, em vez de recusar a chamada. Se o idioma sair errado, o problema e
// o modelo escolhido, nao esta linha, e nenhum erro vai te avisar disso.
if (config.idioma) {
corpo.language_code = config.idioma;
}
Esse é o tipo de coisa que só se descobre apanhando. O modelo que não suporta o campo não recusa a chamada: ele ignora o campo, responde 200 e devolve um áudio com a pronúncia errada. Você fica olhando para a linha que manda o language_code, conferindo se escreveu pt certinho, quando o culpado é o modelo que você escolheu lá em cima.
📼 O formato de saída vai na URL, não no corpo
Essa aqui eu confesso que tentei do jeito errado primeiro, porque é o jeito que faz sentido: o texto vai no corpo, o modelo vai no corpo, então o formato também iria no corpo, certo? Errado. 😳
// O formato de saida vai na URL, como parametro, e nao dentro do corpo.
const url = BASE + "/v1/text-to-speech/" + encodeURIComponent(vozId) +
"?output_format=" + encodeURIComponent(formato);
O output_format é parâmetro de query string. E o pior é que colocá-lo no corpo não dá erro nenhum: a API simplesmente ignora o campo que não conhece e devolve o formato padrão. Você pede um WAV de 16 kHz, recebe um MP3, e só descobre quando abre o arquivo.
Os valores seguem um padrão fácil de ler, tipo_taxa_bitrate:
mp3_44100_128 MP3, 44,1 kHz, 128 kbps o padrao, bom para ouvir
mp3_22050_32 MP3, 22 kHz, 32 kbps arquivo pequeno, qualidade menor
wav_44100 WAV sem compressao para editar depois
pcm_16000 PCM cru, 16 kHz para jogar em outro programa
ulaw_8000 u-law, 8 kHz o formato da telefonia
Vale saber que os formatos mais pesados (o MP3 de 192 kbps e os WAV e PCM de 44,1 kHz) são liberados só nos planos pagos mais altos. Nos planos de baixo eles respondem erro, então comece pelo mp3_44100_128.
💾 Gravando o arquivo: os bytes vêm no corpo
Aqui está a parte boa da ElevenLabs: não tem job, não tem polling, não tem link temporário para baixar depois. O áudio volta nos bytes da própria resposta, e gravar é isso:
// Grava os bytes em disco, criando a pasta se ela nao existir.
function gravar(destino, bytes) {
fs.mkdirSync(path.dirname(destino), { recursive: true });
fs.writeFileSync(destino, bytes);
}
Mas antes de gravar tem uma checagem que parece boba e não é. Repare na ordem:
// O caminho de erro responde JSON; o de sucesso responde bytes de audio.
// Por isso a checagem vem ANTES de ler o corpo: ler como texto primeiro
// corromperia o audio, e ler como bytes primeiro esconderia a mensagem.
if (!resposta.ok) {
const corpoErro = await resposta.text();
throw new Error("A ElevenLabs respondeu " + resposta.status + ": " + corpoErro);
}
return Buffer.from(await resposta.arrayBuffer());
Essa ordem não é decoração. A resposta tem dois formatos possíveis: quando dá certo vêm bytes de áudio, quando dá errado vem um JSON com a explicação. Se você ler o corpo como texto para "dar uma olhada" antes de checar o status, corrompe o áudio do caminho feliz. Se ler como bytes direto, transforma a mensagem de erro num punhado de bytes ilegíveis e perde a única pista do que aconteceu. Por isso o if vem no meio.
🔇 O bug do áudio mudo, que não dá erro nenhum
Se você só for ler um pedaço deste artigo, leia este. 🙏
Um áudio vazio chega para você como HTTP 200. Exatamente igual a um áudio bom. Ele grava no disco, fica com a extensão certa, o tamanho aparece no explorador de arquivos, o player abre sem reclamar, a barrinha anda até o fim... e não sai som nenhum.
O motivo é que um cabeçalho ID3 na frente de um monte de bytes zerados é um MP3 tecnicamente válido. É silêncio, mas é um arquivo legítimo, e nada no seu código tem como desconfiar. Duas linhas resolvem:
// Um MP3 valido comeca com "ID3" ou com o byte 0xFF do primeiro frame.
// Arquivo de zero byte ou cheio de silencio nao da erro nenhum: ele
// grava, o player abre e simplesmente nao sai som.
if (bytes.length < 1024) {
throw new Error("A resposta veio com apenas " + bytes.length + " bytes: audio vazio.");
}
E tem o irmão desse problema, que é o arquivo que fica para trás quando algo falha no meio. Se já existia um áudio bom naquele caminho e a geração nova falhou, o antigo continua lá, respondendo para sempre no lugar do novo. Quem limpa é o catch:
} catch (erro) {
// Se algo deu errado no meio, o arquivo pela metade tem de sair. Ele
// fica com cara de audio bom no disco e responde por sempre no lugar
// do certo, sem ninguem desconfiar.
if (fs.existsSync(destino)) {
fs.unlinkSync(destino);
}
Repare que esse catch faz alguma coisa, ele não só imprime o erro e segue a vida. É a diferença entre ter um try por educação e ter um try que evita um estrago.
📜 O script inteiro
São 145 linhas, lidas de cima para baixo, sem classe e sem abstração nenhuma. O main() fica no fim, como manda o figurino:
// Gera um arquivo de audio a partir de um texto, usando a API da ElevenLabs.
// Node.js 18 ou mais novo: o fetch ja vem junto, nao precisa instalar nada.
const fs = require("node:fs");
const path = require("node:path");
// Repare que a lista de vozes e a geracao de audio estao em versoes
// DIFERENTES da API: as vozes ja estao na v2, a fala continua na v1. Nao e
// engano de digitacao, e trocar um pelo outro devolve 404.
const BASE = process.env.ELEVENLABS_URL || "https://api.elevenlabs.io";
const CHAVE = process.env.ELEVENLABS_API_KEY;
// Listar as vozes da conta. O voice_id e o que voce precisa guardar.
async function listarVozes() {
// ATENCAO ao page_size: sem ele a API devolve so as 10 primeiras vozes, e
// devolve com HTTP 200. A sua voz pode existir e nao aparecer na lista, sem
// erro nenhum para te avisar. O maximo permitido e 100.
const resposta = await fetch(BASE + "/v2/voices?page_size=100", {
headers: { "xi-api-key": CHAVE },
});
if (!resposta.ok) {
const corpo = await resposta.text();
throw new Error("Nao consegui listar as vozes (HTTP " + resposta.status + "): " + corpo);
}
const dados = await resposta.json();
return dados.voices.map(function (voz) {
return { id: voz.voice_id, nome: voz.name, categoria: voz.category };
});
}
// Gera o audio e devolve os bytes dele.
async function gerarAudio(vozId, texto, opcoes) {
const config = opcoes || {};
const modelo = config.modelo || "eleven_multilingual_v2";
const formato = config.formato || "mp3_44100_128";
const corpo = {
text: texto,
model_id: modelo,
voice_settings: {
// 0 = mais emocao e mais variacao entre uma geracao e outra.
// 1 = mais monotono, mas igual toda vez que voce rodar.
stability: config.estabilidade === undefined ? 0.5 : config.estabilidade,
// O quanto colar na voz original. Subir demais traz junto o chiado
// e o eco da gravacao de origem, entao 1 raramente e o melhor valor.
similarity_boost: config.similaridade === undefined ? 0.75 : config.similaridade,
},
};
// ATENCAO: o modelo que NAO entende language_code simplesmente IGNORA o
// campo, em vez de recusar a chamada. Se o idioma sair errado, o problema e
// o modelo escolhido, nao esta linha, e nenhum erro vai te avisar disso.
if (config.idioma) {
corpo.language_code = config.idioma;
}
// O formato de saida vai na URL, como parametro, e nao dentro do corpo.
const url = BASE + "/v1/text-to-speech/" + encodeURIComponent(vozId) +
"?output_format=" + encodeURIComponent(formato);
const resposta = await fetch(url, {
method: "POST",
// A ElevenLabs autentica por um header proprio. Nao e Bearer: mandar
// "Authorization: Bearer ..." devolve 401 sem explicar o motivo.
headers: {
"xi-api-key": CHAVE,
"Content-Type": "application/json",
},
body: JSON.stringify(corpo),
});
// O caminho de erro responde JSON; o de sucesso responde bytes de audio.
// Por isso a checagem vem ANTES de ler o corpo: ler como texto primeiro
// corromperia o audio, e ler como bytes primeiro esconderia a mensagem.
if (!resposta.ok) {
const corpoErro = await resposta.text();
throw new Error("A ElevenLabs respondeu " + resposta.status + ": " + corpoErro);
}
return Buffer.from(await resposta.arrayBuffer());
}
// Grava os bytes em disco, criando a pasta se ela nao existir.
function gravar(destino, bytes) {
fs.mkdirSync(path.dirname(destino), { recursive: true });
fs.writeFileSync(destino, bytes);
}
async function main() {
if (!CHAVE) {
console.error("Defina a variavel de ambiente ELEVENLABS_API_KEY antes de rodar.");
process.exit(1);
}
const texto = process.argv[2] || "Ola! Este e um teste de voz gerada por computador.";
const destino = process.argv[3] || "audio/amostra.mp3";
const vozes = await listarVozes();
if (vozes.length === 0) {
console.error("A sua conta nao tem nenhuma voz disponivel.");
process.exit(1);
}
console.log("Vozes disponiveis:");
vozes.forEach(function (voz) {
console.log(" " + voz.id + " " + voz.nome + " (" + voz.categoria + ")");
});
const escolhida = vozes[0];
console.log("");
console.log("Gerando com a voz " + escolhida.nome + "...");
try {
const bytes = await gerarAudio(escolhida.id, texto, {
modelo: "eleven_multilingual_v2",
formato: "mp3_44100_128",
estabilidade: 0.5,
similaridade: 0.75,
idioma: "pt",
});
// Um MP3 valido comeca com "ID3" ou com o byte 0xFF do primeiro frame.
// Arquivo de zero byte ou cheio de silencio nao da erro nenhum: ele
// grava, o player abre e simplesmente nao sai som.
if (bytes.length < 1024) {
throw new Error("A resposta veio com apenas " + bytes.length + " bytes: audio vazio.");
}
gravar(destino, bytes);
console.log("Gravado em " + destino + " (" + bytes.length + " bytes)");
} catch (erro) {
// Se algo deu errado no meio, o arquivo pela metade tem de sair. Ele
// fica com cara de audio bom no disco e responde por sempre no lugar
// do certo, sem ninguem desconfiar.
if (fs.existsSync(destino)) {
fs.unlinkSync(destino);
}
console.error("Falhou: " + erro.message);
process.exit(1);
}
}
main();
Rodando, com o texto e o destino na linha de comando:
node gerar-audio.js "Ola meus unicornios! Este audio foi gerado por codigo." audio/amostra.mp3
Vozes disponiveis:
voz1111111111111111 Aurora (premade)
voz2222222222222222 Bruno (premade)
Gerando com a voz Aurora...
Gravado em audio/amostra.mp3 (40003 bytes)
E quando a API recusa a requisição, a mensagem chega inteira, em português, com o corpo do erro junto, em vez de um [object Object]:
Gerando com a voz Aurora...
Falhou: A ElevenLabs respondeu 422: {"detail":[{"loc":["body","text"],
"msg":"field required","type":"value_error"}]}
💸 Uma palavra sobre a conta
A ElevenLabs cobra por caractere gerado, e isso muda como você escreve o código em volta dela. Não é o tipo de API que você chama dentro de um laço sem pensar. 😬
Duas consequências práticas. A primeira: se o seu programa gera sempre a mesma frase (uma saudação fixa, um menu), gere uma vez e guarde o arquivo. Regerar o mesmo áudio a cada vez que alguém abre a página transforma uma listagem numa despesa recorrente, e a conta cresce sem ninguém perceber, porque cada chamada isolada é baratinha.
A segunda: se o texto vem de fora (de um formulário, de outro sistema), ponha um teto no tamanho antes de mandar. Um campo de texto sem limite é um convite para alguém colar um livro inteiro ali dentro e a fatura chegar gorda no fim do mês.
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Qual header autentica a API da ElevenLabs?
xi-api-key, com a chave crua dentro. Não é Authorization: Bearer: mandar no formato Bearer devolve 401 sem explicar que o problema foi o nome do header, e é fácil perder um tempão achando que a chave está errada.Como escolher o formato do áudio (MP3, WAV, PCM)?
output_format na query string da URL, não dentro do corpo JSON. O padrão é mp3_44100_128. Colocar esse campo no corpo não dá erro: a API ignora e devolve o formato padrão, então você só descobre olhando o arquivo.O que fazem a estabilidade e a similaridade?
stability vai de 0 a 1 (padrão 0,5): perto de 0 a voz fica mais emotiva e sai diferente a cada geração; perto de 1 fica mais monótona e repetível. A similarity_boost (padrão 0,75) diz o quanto colar na voz original, e subir para 1 costuma trazer junto o chiado da gravação de origem.Por que a minha voz não aparece na lista de vozes?
GET /v2/voices devolve só 10 vozes quando você não manda page_size, e devolve com HTTP 200, sem nenhum aviso. Peça ?page_size=100, que é o máximo, e olhe o campo has_more da resposta.Por que o arquivo gerado abre no player e não sai som?
ID3 na frente são silêncio com cara de arquivo válido.O idioma pode sair errado mesmo mandando language_code?
language_code ignora o campo em vez de recusar a chamada, então a requisição volta 200 e o áudio sai com a pronúncia errada. Se o idioma não obedecer, o culpado é o modelo escolhido, não a linha que manda o campo.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.