Pular para o conteúdo
Node.js

ElevenLabs: gerando samples de voz com Node.js

Ilustração colorida de um unicórnio de crina arco-íris cantando diante de um microfone de estúdio, com ondas sonoras virando um arquivo de áudio que uma coruja recolhe num pergaminho, e uma varinha ajustando dois botões giratórios

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?
É um header próprio, 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)?
Pelo parâmetro 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?
A 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?
Quase sempre é a paginação. O 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?
Porque um áudio vazio chega como HTTP 200, igualzinho a um áudio bom: ele grava, o player abre e simplesmente não toca. Por isso vale conferir o tamanho antes de gravar. Um MP3 de verdade tem dezenas de milhares de bytes; alguns bytes com um cabeçalho ID3 na frente são silêncio com cara de arquivo válido.
O idioma pode sair errado mesmo mandando language_code?
Pode. O modelo que não suporta 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