Pular para o conteúdo
Node.js

ElevenLabs: clonando vozes com Node.js

Ilustração colorida de um unicórnio de crina luminosa cantando em um microfone de estúdio, uma coruja segurando um pergaminho com ondas sonoras, e três frascos de poção sendo despejados num caldeirão do qual sai um segundo unicórnio idêntico feito de luz

Olá meus Unicórnios! 🦄✨

Sabe quando você olha a documentação, vê que é um POST com uns arquivos anexados e pensa "isso é meia hora"? 😅 Pois é. Clonar uma voz na ElevenLabs realmente é um POST com arquivos anexados. O que me tomou a tarde foi uma linha de código que eu tinha escrito com a melhor das intenções, e que estava justamente sabotando o envio.

Neste artigo eu vou do zero até a voz criada: como é o material de amostra que dá um clone bom, como enviar os áudios, como listar o que existe na conta e como apagar uma voz do jeito certo. Um arquivo só de Node.js, lido de cima para baixo, sem nenhuma dependência instalada.

Um aviso antes de qualquer linha de código, e ele não é burocracia: você precisa da autorização da pessoa cuja voz vai clonar. Voz é dado biométrico, identifica alguém como uma digital identifica. Volto nisso no fim, com o que fazer na hora de apagar.

🎙️ O que é o clone instantâneo

A ElevenLabs tem dois caminhos para criar uma voz, e eles não se parecem nem um pouco na hora de programar.

O clone instantâneo é o deste artigo: você manda de 1 a 5 amostras curtas e a resposta já vem com a voz pronta para usar. É uma chamada só, síncrona, e acabou. O outro caminho é o clone profissional, que pede dezenas de minutos de áudio, treina de verdade e demora horas: ali você envia o material, recebe um "estamos processando" e volta depois. São dois fluxos diferentes, e misturar os dois na cabeça é o que faz a pessoa procurar um endpoint de "status do treinamento" que, no caso instantâneo, não existe.

Para o instantâneo, o endereço é este:

POST https://api.elevenlabs.io/v1/voices/add

E a resposta é curtinha, com o identificador que você vai usar daí em diante:

{
    "voice_id": "cloned2",
    "requires_verification": false
}

🔑 A chave, e o cabeçalho que quase ninguém acerta de primeira

Primeiro a chave. Ela sai do painel da ElevenLabs, em My Account → API Keys, e nunca vai escrita dentro do arquivo. Ela mora numa variável de ambiente, que você define antes de rodar o script.

No Windows, no PowerShell:

$env:ELEVENLABS_API_KEY = "cole_a_sua_chave_aqui"

No Linux ou no Mac:

export ELEVENLABS_API_KEY="cole_a_sua_chave_aqui"

Essa variável vale só para a janela de terminal onde você digitou. Fechou a janela, ela some, e é bom que seja assim: chave de API não é coisa para ficar espalhada pelo sistema.

Agora o detalhe que custa tempo. Quase toda API que você já usou autentica assim:

Authorization: Bearer sua_chave

A ElevenLabs não. Ela tem um cabeçalho próprio, com a chave crua no valor:

xi-api-key: sua_chave

Parece bobagem, mas repare no que acontece quando você erra: o servidor recebe uma requisição sem credencial nenhuma e responde um 401 de autorização. Aí você vai conferir a chave, gerar outra, colar de novo, conferir espaço no começo e no fim. E a chave estava certa o tempo todo: o que estava errado era o nome do cabeçalho, que você nem desconfiou de olhar.

🎧 O material de amostra: o que faz um clone bom

Essa parte não tem código nenhum, e é a que mais decide o resultado. A API aceita quase tudo que você mandar e devolve uma voz de qualquer jeito. Se a voz sair estranha, ela não vai te avisar do motivo.

O que importa nas amostras:

  • Uma pessoa só. Se tiver um segundo falante no fundo, mesmo baixinho, ele entra na mistura e a voz clonada sai com um sotaque esquisito que ninguém consegue explicar.
  • Silêncio em volta. Nada de música de fundo, ar-condicionado, ventilador ou eco de sala vazia. O modelo não separa a voz do ambiente: ele aprende os dois juntos.
  • Fala natural e contínua. Alguém contando alguma coisa em ritmo normal funciona muito melhor que uma leitura empostada, palavra por palavra.
  • Consistência. As cinco amostras têm que ser do mesmo microfone, do mesmo dia, do mesmo jeito de falar. Misturar uma gravação de celular com uma de microfone bom entrega uma voz que fica oscilando entre as duas.

Sobre a duração: cada amostra na faixa de trinta segundos a poucos minutos dá conta. E vale dizer uma coisa que contraria a intuição de todo mundo: mandar mais áudio ruim é pior que mandar pouco áudio bom. Uma amostra limpa de um minuto bate cinco amostras chiadas de três minutos cada, sem contest.

🕵️ Conferindo se o arquivo é mesmo áudio

Antes de gastar uma chamada cobrada, vale conferir o que você está prestes a enviar. E a conferência óbvia é a errada: olhar a extensão do nome não serve de nada.

O nome do arquivo é só texto, digitado por uma pessoa. Um documento renomeado para amostra.mp3 continua sendo um documento. Quem sabe de verdade o que tem ali dentro são os primeiros bytes do arquivo, porque todo formato de áudio começa com uma marca fixa:

MP3 com etiqueta    os tres primeiros bytes sao  ID3
MP3 sem etiqueta    o primeiro byte e FF e os tres bits seguintes ligados
WAV                 os quatro primeiros bytes sao  RIFF
OGG                 os quatro primeiros bytes sao  OggS
FLAC                os quatro primeiros bytes sao  fLaC
M4A e MP4           a caixa  ftyp  comeca no QUARTO byte, nao no primeiro
WebM                os quatro primeiros bytes em hexadecimal sao 1a45dfa3

Repare no caso do M4A, que é o que pega quem escreve essa função pela primeira vez: a marca dele não está no começo do arquivo. Os quatro primeiros bytes são o tamanho da caixa, e só depois vem o ftyp. Se você comparar a partir do byte zero, todo M4A vai ser rejeitado como "não é áudio", e o arquivo toca perfeitamente no seu player, o que torna o defeito bem difícil de engolir.

A função que faz essa conferência é esta:

function descobrirTipoDeAudio(bytes) {
    if (bytes.length < 12) {
        return null;
    }
    if (bytes.slice(0, 3).toString("latin1") === "ID3") {
        return "audio/mpeg";
    }
    // MP3 sem etiqueta ID3: o quadro comeca com 11 bits ligados.
    if (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) {
        return "audio/mpeg";
    }
    if (bytes.slice(0, 4).toString("latin1") === "RIFF") {
        return "audio/wav";
    }
    if (bytes.slice(0, 4).toString("latin1") === "OggS") {
        return "audio/ogg";
    }
    if (bytes.slice(0, 4).toString("latin1") === "fLaC") {
        return "audio/flac";
    }
    // M4A e MP4: a caixa "ftyp" comeca no quarto byte, nao no primeiro.
    if (bytes.slice(4, 8).toString("latin1") === "ftyp") {
        return "audio/mp4";
    }
    // WebM (e MKV): assinatura do conteiner Matroska.
    if (bytes.slice(0, 4).toString("hex") === "1a45dfa3") {
        return "audio/webm";
    }
    return null;
}

E tem um bônus nisso, que é o motivo de eu devolver o tipo em vez de só um true: esse valor é exatamente o que vai no Content-Type de cada arquivo dentro do envio, logo adiante. Descobrir e anunciar o tipo viram a mesma coisa.

💣 A armadilha do Content-Type

Chegamos na linha que me tomou a tarde. 😳

Enviar arquivos por HTTP é multipart/form-data: o corpo da requisição vira uma sequência de pedaços, um por campo, grudados um atrás do outro. Como os arquivos são bytes binários que podem conter qualquer coisa, precisa existir um separador que com certeza não aparece dentro deles. Esse separador é o boundary, um texto aleatório sorteado na hora do envio.

E aqui está o detalhe cruel: o boundary sorteado viaja dentro do cabeçalho Content-Type. É assim que o outro lado descobre qual é o separador daquele envio específico. O cabeçalho de verdade tem esta cara:

content-type: multipart/form-data; boundary=----formdata-undici-098976783725

Agora imagine que você, sendo caprichoso, escreve o cabeçalho à mão nos headers da requisição, porque toda API que você já usou precisou disso:

// NAO faca isto quando o corpo e um FormData!
const resposta = await fetch(url, {
    method: "POST",
    headers: {
        "xi-api-key": CHAVE,
        "Content-Type": "multipart/form-data"
    },
    body: formulario
});

O que você acabou de fazer foi substituir o cabeçalho que o fetch ia montar sozinho por um que não tem o boundary. Os arquivos vão no corpo, direitinhos, com o separador certo entre eles. Só que o cabeçalho não conta qual é o separador. Do outro lado, ninguém consegue mais achar onde cada arquivo começa e termina.

Botei os dois lados lado a lado para ver a diferença. Mesmo arquivo, mesma chave, mesmo endereço, mudando só o cabeçalho:

--- deixando o fetch montar o Content-Type ---
   Content-Type enviado: deixado a cargo do fetch
   HTTP 200  {"voice_id":"cloned2","requires_verification":false}

--- escrevendo Content-Type a mao ---
   Content-Type enviado: multipart/form-data  (escrito a mao)
   HTTP 400  {"detail":"multipart sem boundary"}

A regra, então, é curtinha e vale para qualquer API que receba arquivo: quando o corpo é um FormData, não escreva o Content-Type. Deixe o fetch montar. É um daqueles casos em que fazer menos é fazer certo.

No script eu resolvi isso num lugar só, com um if que decide o cabeçalho conforme o tipo do corpo:

// ATENCAO: quando o corpo e um FormData, o Content-Type NAO se escreve
// a mao. O fetch precisa monta-lo sozinho porque ele carrega o
// "boundary", o separador aleatorio entre um arquivo e outro. Escrever
// "multipart/form-data" aqui manda um cabecalho sem esse separador, e do
// outro lado ninguem consegue mais achar onde cada arquivo comeca.
let corpoFinal = corpo;
if (corpo !== undefined && !(corpo instanceof FormData)) {
    cabecalhos["Content-Type"] = "application/json";
    corpoFinal = JSON.stringify(corpo);
}

Ou seja: se for FormData, não mexe em nada. Se for qualquer outra coisa, aí sim vira JSON e ganha o cabeçalho de JSON.

📤 Montando o envio das amostras

Com a armadilha fora do caminho, montar o envio é tranquilo. Dois detalhes valem ser ditos em voz alta.

O primeiro é o nome do campo dos arquivos. Ele é files, e você repete o mesmo nome para cada arquivo. Não é files[], que é o hábito de quem vem de PHP, nem files0, files1. É files cinco vezes:

const formulario = new FormData();
formulario.append("name", nome);

// O campo se chama "files" e repete para cada arquivo. Nao e "files[]"
// nem "files0": e o mesmo nome, varias vezes.
for (const amostra of amostras) {
    const arquivo = new Blob([amostra.bytes], { type: amostra.tipo });
    formulario.append("files", arquivo, amostra.nome);
}

O segundo é a ordem das coisas, que parece detalhe e não é. Repare que eu leio e confiro todas as amostras antes de montar o formulário:

// Le e confere TODAS as amostras antes de enviar qualquer coisa. Se a
// quinta estiver quebrada, ninguem gastou uma chamada com as quatro boas.
const amostras = [];
for (const caminho of caminhos) {
    const amostra = lerAmostra(caminho);
    console.log(
        "  amostra: " + amostra.nome + "  " + amostra.tipo +
        "  " + amostra.bytes.length + " bytes"
    );
    amostras.push(amostra);
}

Se eu fosse conferindo e enviando de pouco em pouco, um arquivo ruim no fim da fila me deixaria com uma chamada gasta e uma voz pela metade na conta. Conferindo tudo antes, o erro acontece do lado de cá, de graça, e você corrige e roda de novo.

O terceiro argumento do append, o nome do arquivo, também não é enfeite: é ele que vira o filename dentro do envio. Sem ele, todas as amostras chegam como blob, e você perde qualquer pista de qual era qual quando precisar depurar.

📜 O arquivo inteiro

Aqui está o script completo. É um arquivo só, sem nada para instalar: o fetch, o FormData e o Blob já vêm dentro do Node.js moderno. Salve como vozes.js:

// Clonando vozes na ElevenLabs com Node.js.
// Rode com: node vozes.js clonar "Nome da voz" amostra1.mp3 amostra2.mp3
//           node vozes.js listar
//           node vozes.js apagar <voice_id>

const fs = require("fs");
const path = require("path");

// A chave NUNCA fica escrita no arquivo. Ela vem do ambiente:
//   Windows (PowerShell):  $env:ELEVENLABS_API_KEY = "sua_chave"
//   Linux e Mac:           export ELEVENLABS_API_KEY="sua_chave"
const CHAVE = process.env.ELEVENLABS_API_KEY;

// Da para apontar para outro endereco em teste. Em uso normal, nao mexa.
const BASE = process.env.ELEVENLABS_BASE_URL || "https://api.elevenlabs.io/v1";

// Limites da nossa borda. A ElevenLabs tambem recusaria, mas falhar aqui
// da mensagem melhor e nao gasta uma chamada cobrada.
const MAXIMO_DE_AMOSTRAS = 5;
const MAXIMO_DE_BYTES = 10 * 1024 * 1024;

// Cada formato de audio comeca com uma marca fixa nos primeiros bytes.
// Conferimos o CONTEUDO, e nao a extensao: o nome do arquivo e digitado por
// uma pessoa, e um ".mp3" no fim nao prova nada sobre o que tem dentro.
function descobrirTipoDeAudio(bytes) {
    if (bytes.length < 12) {
        return null;
    }
    if (bytes.slice(0, 3).toString("latin1") === "ID3") {
        return "audio/mpeg";
    }
    // MP3 sem etiqueta ID3: o quadro comeca com 11 bits ligados.
    if (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) {
        return "audio/mpeg";
    }
    if (bytes.slice(0, 4).toString("latin1") === "RIFF") {
        return "audio/wav";
    }
    if (bytes.slice(0, 4).toString("latin1") === "OggS") {
        return "audio/ogg";
    }
    if (bytes.slice(0, 4).toString("latin1") === "fLaC") {
        return "audio/flac";
    }
    // M4A e MP4: a caixa "ftyp" comeca no quarto byte, nao no primeiro.
    if (bytes.slice(4, 8).toString("latin1") === "ftyp") {
        return "audio/mp4";
    }
    // WebM (e MKV): assinatura do conteiner Matroska.
    if (bytes.slice(0, 4).toString("hex") === "1a45dfa3") {
        return "audio/webm";
    }
    return null;
}

// Le um arquivo de amostra do disco e confere se ele serve.
function lerAmostra(caminho) {
    if (!fs.existsSync(caminho)) {
        throw new Error("Arquivo nao encontrado: " + caminho);
    }

    const bytes = fs.readFileSync(caminho);

    if (bytes.length > MAXIMO_DE_BYTES) {
        throw new Error(
            "O arquivo " + path.basename(caminho) + " tem " +
            Math.round(bytes.length / 1024 / 1024) + " MB e o limite e 10 MB"
        );
    }

    const tipo = descobrirTipoDeAudio(bytes);
    if (tipo === null) {
        throw new Error(
            "O arquivo " + path.basename(caminho) +
            " nao parece audio de verdade (MP3, WAV, M4A, OGG, WebM ou FLAC)"
        );
    }

    return {
        nome: path.basename(caminho),
        tipo: tipo,
        bytes: bytes
    };
}

// Uma unica porta de entrada para a API. Todas as chamadas passam aqui,
// entao o cabecalho de autenticacao e o tratamento de erro ficam num lugar so.
async function chamarApi(metodo, caminho, corpo) {
    if (!CHAVE) {
        throw new Error(
            "Falta a variavel de ambiente ELEVENLABS_API_KEY"
        );
    }

    // A ElevenLabs autentica por um cabecalho proprio, "xi-api-key".
    // NAO e "Authorization: Bearer", que e o mais comum por ai.
    const cabecalhos = { "xi-api-key": CHAVE };

    // ATENCAO: quando o corpo e um FormData, o Content-Type NAO se escreve
    // a mao. O fetch precisa monta-lo sozinho porque ele carrega o
    // "boundary", o separador aleatorio entre um arquivo e outro. Escrever
    // "multipart/form-data" aqui manda um cabecalho sem esse separador, e do
    // outro lado ninguem consegue mais achar onde cada arquivo comeca.
    let corpoFinal = corpo;
    if (corpo !== undefined && !(corpo instanceof FormData)) {
        cabecalhos["Content-Type"] = "application/json";
        corpoFinal = JSON.stringify(corpo);
    }

    const resposta = await fetch(BASE + caminho, {
        method: metodo,
        headers: cabecalhos,
        body: corpoFinal
    });

    const texto = await resposta.text();

    if (!resposta.ok) {
        throw new Error(
            "A ElevenLabs respondeu " + resposta.status + ": " + texto
        );
    }

    if (texto === "") {
        return null;
    }

    return JSON.parse(texto);
}

// Cria a voz. E o clone instantaneo: a resposta ja traz a voz pronta.
async function clonarVoz(nome, caminhos) {
    if (caminhos.length === 0) {
        throw new Error("Envie ao menos uma amostra de audio");
    }
    if (caminhos.length > MAXIMO_DE_AMOSTRAS) {
        throw new Error(
            "Envie no maximo " + MAXIMO_DE_AMOSTRAS + " amostras"
        );
    }

    // Le e confere TODAS as amostras antes de enviar qualquer coisa. Se a
    // quinta estiver quebrada, ninguem gastou uma chamada com as quatro boas.
    const amostras = [];
    for (const caminho of caminhos) {
        const amostra = lerAmostra(caminho);
        console.log(
            "  amostra: " + amostra.nome + "  " + amostra.tipo +
            "  " + amostra.bytes.length + " bytes"
        );
        amostras.push(amostra);
    }

    const formulario = new FormData();
    formulario.append("name", nome);

    // O campo se chama "files" e repete para cada arquivo. Nao e "files[]"
    // nem "files0": e o mesmo nome, varias vezes.
    for (const amostra of amostras) {
        const arquivo = new Blob([amostra.bytes], { type: amostra.tipo });
        formulario.append("files", arquivo, amostra.nome);
    }

    const resposta = await chamarApi("POST", "/voices/add", formulario);
    return resposta;
}

async function listarVozes() {
    const resposta = await chamarApi("GET", "/voices");
    return resposta.voices;
}

async function apagarVoz(idDaVoz) {
    // O id vem de fora e vai no meio da URL: codificar evita que uma barra
    // ou um "?" no meio dele mude a rota que estamos chamando.
    await chamarApi("DELETE", "/voices/" + encodeURIComponent(idDaVoz));
}

async function main() {
    const comando = process.argv[2];

    if (comando === "clonar") {
        const nome = process.argv[3];
        const caminhos = process.argv.slice(4);

        if (!nome) {
            console.log('Uso: node vozes.js clonar "Nome da voz" amostra1.mp3');
            process.exit(1);
        }

        console.log('Clonando a voz "' + nome + '"...');
        const voz = await clonarVoz(nome, caminhos);

        console.log("");
        console.log("Voz criada!");
        console.log("  voice_id: " + voz.voice_id);
        if (voz.requires_verification) {
            console.log("  ATENCAO: esta voz exige verificacao antes de falar.");
        }
        return;
    }

    if (comando === "listar") {
        const vozes = await listarVozes();
        console.log(vozes.length + " voz(es) na conta:");
        for (const voz of vozes) {
            console.log("  " + voz.voice_id + "  " + voz.category + "  " + voz.name);
        }
        return;
    }

    if (comando === "apagar") {
        const idDaVoz = process.argv[3];
        if (!idDaVoz) {
            console.log("Uso: node vozes.js apagar <voice_id>");
            process.exit(1);
        }
        await apagarVoz(idDaVoz);
        console.log("Voz " + idDaVoz + " apagada.");
        return;
    }

    console.log("Comandos: clonar | listar | apagar");
    process.exit(1);
}

main().catch(function (erro) {
    // Mensagem limpa, em portugues, e codigo de saida 1 para quem chamar o
    // script de dentro de outro programa saber que deu errado.
    console.error("Erro: " + erro.message);
    process.exit(1);
});

▶️ Rodando

Com a variável de ambiente definida na mesma janela de terminal, é só chamar:

node vozes.js clonar "Voz da Paloma" amostra1.mp3 amostra2.mp3 amostra3.wav amostra4.m4a

E a saída:

Clonando a voz "Voz da Paloma"...
  amostra: amostra1.mp3  audio/mpeg  4010 bytes
  amostra: amostra2.mp3  audio/mpeg  4004 bytes
  amostra: amostra3.wav  audio/wav  2044 bytes
  amostra: amostra4.m4a  audio/mp4  1012 bytes

Voz criada!
  voice_id: cloned2

Repare na linha de cada amostra: o tipo ali não foi lido da extensão, foi descoberto nos bytes. O amostra4.m4a aparecendo como audio/mp4 é a prova de que a comparação a partir do quarto byte pegou a caixa ftyp certinho.

Esse voice_id da última linha é o que interessa. É com ele que você manda a voz falar depois, e é com ele que você apaga.

🚨 Quando dá errado

Todo caminho de erro cai no mesmo catch lá no fim, que imprime uma linha só e sai com código 1. Sem pilha de erro, sem parágrafo em inglês. Os quatro que você mais vai ver:

Esqueceu a variável de ambiente (ou abriu uma janela de terminal nova, que é o caso mais comum):

Erro: Falta a variavel de ambiente ELEVENLABS_API_KEY

Chave errada, ou o cabeçalho xi-api-key escrito errado:

Erro: A ElevenLabs respondeu 401: {"detail":{"status":"needs_authorization","message":"Needs authorization"}}

Um arquivo que não é áudio no meio da fila. Repare que a primeira amostra já tinha sido lida e conferida, e mesmo assim nada foi enviado: a conferência de todas acontece antes do envio de qualquer uma:

Clonando a voz "Teste"...
  amostra: amostra1.mp3  audio/mpeg  4010 bytes
Erro: O arquivo naoeaudio.mp3 nao parece audio de verdade (MP3, WAV, M4A, OGG, WebM ou FLAC)

E um voice_id que não existe na hora de apagar:

Erro: A ElevenLabs respondeu 400: {"detail":{"status":"voice_not_found","message":"A voice with voice_id naoexiste was not found."}}

Esse último tem um detalhe que vale guardar: apagar uma voz que não existe responde 400, e não 404 como você esperaria. Se o seu código tratar só o 404 como "já não está lá", ele vai explodir num caso que era para ser inofensivo.

📋 Listando o que existe na conta

O GET /v1/voices devolve tudo que a sua conta enxerga, e isso inclui as vozes prontas da ElevenLabs, não só as suas:

node vozes.js listar
2 voz(es) na conta:
  21m00Tcm4TlvDq8ikWAM  premade  Rachel
  cloned2  cloned  Voz da Paloma

É por isso que eu imprimo o campo category no meio. As que você criou vêm como cloned; as que já vieram com a conta, como premade. Sem olhar essa coluna, você vê uma lista com dezenas de vozes e não faz ideia de quais são suas, e é bem fácil apagar a errada.

🗑️ Apagando uma voz, e o que a API não apaga por você

Apagar é direto:

node vozes.js apagar cloned2
Voz cloned2 apagada.

Isso importa por um motivo prático: o seu plano tem um número máximo de vozes, e voz esquecida ocupa vaga. Quando a criação começar a falhar por limite, é quase certo que a conta esteja cheia de testes de meses atrás.

Mas tem uma parte que a API não faz por você, e é a mais importante deste artigo inteiro.

O DELETE revoga a voz lá na ElevenLabs. Ele não toca nos arquivos de amostra que você guardou no seu servidor. Se o seu sistema salva uma cópia dos áudios enviados (e quase todo sistema salva, para poder reenviar depois), apagar esses arquivos é trabalho seu, no mesmo passo em que você apaga a voz.

Esse é o pedaço que sempre fica para depois. E é o que não pode ficar: quando alguém pede para remover a voz, essa pessoa está retirando o consentimento que deu. Continuar guardando o áudio dela no disco contraria exatamente o pedido que acabou de ser feito, mesmo que a voz já não exista mais no provedor.

Uma dica de implementação que eu aprendi da pior forma: quando for apagar a pasta de amostras, trate a falha como aviso, não como erro fatal. A pasta pode já ter sumido por um monte de motivos, e se isso derrubar a operação inteira a voz fica no pior estado possível: revogada lá fora e ainda ativa no seu sistema. Apague o que der, registre o que não deu, e siga com a remoção até o fim.

E é isso! A voz está criada, você tem o voice_id na mão, sabe listar o que existe e sabe apagar sem deixar rastro pelo caminho. 💜

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Por que a ElevenLabs recusa o meu upload de amostras sem explicar o motivo?
Quase sempre é o Content-Type escrito à mão. Em multipart/form-data o cabeçalho precisa carregar junto um boundary, que é o separador aleatório entre um arquivo e outro. Quando você passa "Content-Type": "multipart/form-data" nos headers, esse separador não vai, e do outro lado ninguém consegue achar onde cada arquivo começa. A correção é não escrever o cabeçalho: com um FormData no corpo, o fetch monta o valor completo sozinho.
Qual é o cabeçalho de autenticação da API da ElevenLabs?
É xi-api-key, com a chave crua no valor. Não é Authorization: Bearer, que é o padrão da maioria das APIs e o primeiro chute de quase todo mundo. Com o cabeçalho errado a resposta é um 401 de autorização, que faz você procurar defeito na chave em vez de procurar no nome do cabeçalho.
Quantas amostras de áudio preciso enviar para clonar uma voz?
O clone instantâneo aceita de 1 a 5 arquivos. Mais importante que a quantidade é o material: a voz de uma pessoa só, em ambiente silencioso, falando naturalmente. Amostra com duas pessoas conversando, música de fundo ou eco de sala produz um clone ruim, e a API não reclama disso: ela aceita e devolve uma voz que sai estranha.
Como saber se o arquivo enviado é mesmo um áudio?
Olhando os primeiros bytes, não a extensão. Cada formato começa com uma marca fixa: MP3 com ID3, WAV com RIFF, OGG com OggS, FLAC com fLaC, e M4A com a caixa ftyp a partir do quarto byte. O nome do arquivo é digitado por uma pessoa, então um .mp3 no fim não prova nada sobre o conteúdo.
O que acontece com as amostras de áudio quando eu apago a voz?
O DELETE /v1/voices/{voice_id} revoga a voz lá e libera a vaga do seu plano. Mas as cópias das amostras que você guardou no seu servidor continuam onde estavam: apagar essas é trabalho seu, no mesmo passo. É voz biométrica de uma pessoa real, coletada com um consentimento que acabou de ser retirado.
Preciso de autorização da pessoa para clonar a voz dela?
Precisa, e isso não é formalidade. Voz é dado biométrico, identifica a pessoa como uma digital, e clonar sem consentimento explícito viola tanto os termos da ElevenLabs quanto a LGPD. Guarde a autorização por escrito dizendo para que a voz vai ser usada, e trate as amostras como dado sensível de verdade.

Leia também