Pular para o conteúdo
Ollama

Texto em áudio por CURL, com IA local na sua VPS

Ilustração colorida de um pergaminho mágico cujas letras viram ondas sonoras luminosas saindo de uma trombeta encantada, com um unicórnio de crina arco-íris cantando ao lado e um castelo de velas flutuantes ao fundo

Olá meus Unicórnios! 🦄✨

A gente já ensinou a IA local a ouvir. Hoje vamos ensinar ela a falar. 🗣️

Você escreve um texto, manda por CURL e recebe um MP3 de volta, com uma voz lendo o que você escreveu. Serve para transformar artigo em áudio, para dar voz a um robô de atendimento, para ouvir no carro aquele relatório que você não tem tempo de ler, ou simplesmente porque é mágico ver uma frase virar som numa máquina que é sua.

E é o mesmo ângulo dos dois artigos anteriores: a IA que vai falar é local, roda no seu próprio servidor. 🏠 O texto não sai da sua máquina, não tem chave de terceiro para criar, não tem cartão para cadastrar. Os serviços de voz da nuvem cobram por caractere, então um livro inteiro vira uma fatura; aqui a conta da VPS é a mesma esteja ela gerando áudio o dia todo ou parada de braços cruzados. 💸

Só que dessa vez a história começa com uma decepção. 😅

😤 O 404 que abre este artigo

No artigo do Whisper eu contei uma surpresa boa: a transcrição já estava instalada e eu nem sabia. Aqui aconteceu o contrário, e vale começar pelo tropeço.

Com tudo do jeito que estava, tentei pedir um áudio ao Open WebUI. O endereço é irmão do de transcrição, só troca o fim:

curl -X POST http://SEU_IP_AQUI:3000/api/v1/audio/speech \
  -H "Authorization: Bearer sk-SUA_CHAVE_AQUI" \
  -H 'Content-Type: application/json' \
  -d '{"input":"Ola meus unicornios"}'

E a resposta foi esta:

{
    "detail": "We could not find what you're looking for :/"
}

HTTP 404. Não é "chave inválida", não é "faltou um campo": é o servidor dizendo que esse endereço não existe. 🫠

O motivo aparece no mesmo lugar onde o artigo passado achou a prova de que o Whisper era local: a configuração de áudio. Peça ela de novo, com o mesmo comando de lá:

curl -s http://SEU_IP_AQUI:3000/api/v1/audio/config \
  -H "Authorization: Bearer sk-SUA_CHAVE_AQUI"

Só que agora a gente olha o outro pedaço da resposta, o de baixo:

{
    "tts": {
        "ENGINE": "",
        "MODEL": "tts-1",
        "VOICE": "alloy"
    }
}

Repare no "ENGINE": "". É o mesmo campo vazio do artigo passado, e aqui ele quer dizer uma coisa completamente diferente. 🤯

CampoVazio quer dizerDá para chamar por CURL?
stt.ENGINE (ouvir)Whisper local, dentro do servidorSim
tts.ENGINE (falar)Voz do navegador, no seu computadorNão

É por isso que o botão de alto-falante do painel funciona e a API não. Quando você clica nele, quem lê o texto é o seu navegador, com a mesma voz robótica que o Windows usa desde sempre. O servidor não gera áudio nenhum, então não tem endpoint para chamar. O MODEL e a VOICE preenchidos ali embaixo são só valores esperando um motor aparecer.

Ou seja: a gente vai ter que instalar o motor de voz. E é surpreendentemente rápido.

🎺 Subindo o motor de voz

O programa que vamos usar se chama Piper, e ele é o queridinho da turma de voz offline: roda em CPU, é leve, e tem vozes em dezenas de idiomas, português brasileiro incluído.

Só que o Piper sozinho é um programa de linha de comando, e a gente quer uma API. Por sorte existe um contêiner pronto que embrulha o Piper e fala o protocolo da OpenAI.

Deixa eu explicar essa expressão, porque ela assusta e é simples: "falar o protocolo da OpenAI" quer dizer só que o formato do pedido e da resposta são iguais aos que a OpenAI usa. Mesmo endereço (/v1/audio/speech), mesmos nomes de campo (model, voice, input). Nada vai para a OpenAI, não existe chave nem conexão com eles. É como um copiar o formulário do outro: o papel é igual, quem preenche e quem recebe é você. E é justamente isso que vai fazer o Open WebUI conversar com ele mais tarde, sem adaptação nenhuma.

Primeiro, crie as duas pastas onde os arquivos vão morar (o mkdir cria pasta, e o -p cria as intermediárias que faltarem):

mkdir -p /opt/tts/vozes /opt/tts/config

Agora o comando que sobe o contêiner:

docker run -d --name tts --restart always \
  -p 172.17.0.1:8000:8000 \
  -v /opt/tts/vozes:/app/voices \
  -v /opt/tts/config:/app/config \
  ghcr.io/matatonic/openedai-speech-min:latest

Duas linhas dele merecem atenção. O -p 172.17.0.1:8000:8000 publica a porta só na ponte do Docker, e não na internet. Os dois -v guardam as vozes e a configuração em pastas do servidor, fora da caixinha, para que atualizar a imagem não apague nada. O resto é o de sempre: -d roda em segundo plano, --name dá o apelido e --restart always sobe de novo depois de um reboot.

A imagem tem 1,23 GB, então o download demora um cafezinho. ☕ Na primeira subida ele ainda baixa as vozes sozinho, e dá para ver isso acontecendo:

docker logs tts
INFO:piper.download:Downloaded voices/en_US-libritts_r-medium.onnx
INFO:piper.download:Downloaded voices/en_GB-northern_english_male-medium.onnx

Para confirmar que ele está de pé, pergunte quais modelos ele conhece:

curl -s http://172.17.0.1:8000/v1/models | jq
{
    "object": "list",
    "data": [
        {
            "id": "tts-1-hd"
        },
        {
            "id": "tts-1"
        }
    ]
}

Dois modelos: o tts-1 e o tts-1-hd. Os nomes são cópia dos da OpenAI, pela mesma razão de antes. 🎉

🔊 A primeira frase falada

Agora o momento bonito. Mande um texto e peça o áudio:

curl -X POST http://172.17.0.1:8000/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{"model":"tts-1","voice":"alloy","input":"Ola meus unicornios, hoje vamos falar sobre inteligencia artificial."}' \
  -o audio.mp3

O -d leva o pedido (qual voz e o texto a falar) e o -o audio.mp3 guarda a resposta no arquivo. Esse último é obrigatório aqui, porque a resposta não é texto: é um MP3. Sem o -o, o terminal cospe os bytes do áudio na tela e vira aquela sujeira de símbolos malucos.

Levou 4 segundos. E dá para conferir que veio mesmo um MP3, sem precisar abrir nada, com o comando file, que olha dentro do arquivo e diz o que ele é de verdade:

file audio.mp3
audio.mp3: Audio file with ID3 version 2.4.0, contains:
- MPEG ADTS, layer III, v2, 64 kbps, 22.05 kHz, Monaural

MP3 mono, 64 kbps, 22.050 Hz. É qualidade de voz, não de música, e é exatamente o que a gente quer: fica leve e é mais que suficiente para alguém entender o que foi dito. 🎧

Só que quando eu ouvi esse arquivo, tomei um susto.

🇬🇧 A armadilha: todas as vozes vêm em inglês

Se você só for ler um pedaço deste artigo, leia este. 🙏

As vozes que vêm no contêiner são seis, com os nomes copiados da OpenAI: alloy, echo, fable, onyx, nova e shimmer. E todas as seis são en_US ou en_GB.

Uma voz de idioma é treinada para pronunciar as letras daquele idioma. Uma voz americana lendo português faz o que um americano que nunca estudou português faria: pronuncia cada letra do jeito inglês e sai uma coisa que só de longe lembra a frase. Não é sotaque charmoso, é sopa. 🫠

E aqui vem a melhor parte deste artigo, que só foi possível porque a gente já tinha construído o tutorial anterior: eu usei o Whisper da mesma VPS para transcrever de volta o áudio gerado.

A ideia é essa: se eu mando uma frase, transformo em áudio e depois peço para a IA escutar esse áudio e escrever o que ouviu, o que voltar é a prova de como ficou a pronúncia. É um teste de ida e volta, e ele é implacável.

A frase original mandada nos dois casos foi esta:

Ola meus unicornios! Hoje vamos transformar texto em audio com inteligencia artificial local.

E foi isso que o Whisper entendeu de volta:

Voz usada para gerarO que o Whisper entendeu de volta
alloy (en_US)"O Lemusa Unicornios, Hodge-Vamos-Falor-Sauber Inteligencia Artificial."
paloma (pt_BR)"Há-la-meus o Mikor News. Hoje vamos transformar texto em audio com inteligência artificial local."

Olhe a primeira linha com carinho. "Hodge-Vamos-Falor-Sauber" é o que sobrou de "hoje vamos transformar". Não é uma palavra errada aqui e ali: a frase inteira se desmanchou. 😳

Já a segunda linha, com a voz brasileira, volta impecável da segunda frase em diante, acento e tudo. Aquele "Há-la-meus o Mikor News" do começo é o Whisper se atrapalhando com "Olá meus unicórnios" mesmo, que é uma saudação que não existe em lugar nenhum e ele nunca viu na vida. 🦄

Ou seja: o motor estava funcionando o tempo todo. O problema era a voz.

🎙️ Baixando uma voz brasileira

As vozes do Piper ficam num repositório público, e você baixa o arquivo direto para a pasta que a gente criou lá em cima. Entre nela primeiro (o cd entra numa pasta):

cd /opt/tts/vozes

E baixe os dois arquivos da voz:

curl -sLO https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/pt/pt_BR/faber/medium/pt_BR-faber-medium.onnx
curl -sLO https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/pt/pt_BR/faber/medium/pt_BR-faber-medium.onnx.json

Aquelas letrinhas do curl valem uma explicação: o -s é de silent, esconde a barra de progresso; o -L manda seguir redirecionamento, que esse site usa; e o -O (letra O maiúscula) grava com o mesmo nome que o arquivo tem lá, em vez de despejar tudo na tela.

São dois arquivos, e os dois são obrigatórios. Essa é a pegadinha que faz o modelo simplesmente não carregar: o .onnx é a voz em si, e o .onnx.json é a receita dela, que diz ao programa como aquele arquivo deve ser lido. Baixar só o primeiro, que é o grandão e parece "o importante", não funciona. 📎

Confira com o ls -la, que lista os arquivos com tamanho (o -l mostra os detalhes e o -a mostra até os ocultos):

ls -la /opt/tts/vozes
63201294  pt_BR-faber-medium.onnx
    4855  pt_BR-faber-medium.onnx.json

Uns 60 MB a voz e 4,8 KB a receita. Uma voz inteira cabendo em 60 MB ainda me impressiona. 🤯

📝 Registrando a voz nova

Baixar o arquivo não basta: é preciso dizer ao programa que ele existe e dar um nome a ele. Isso se faz num arquivo de configuração, e vamos abri-lo com o nano, que é o editor de texto mais simples do Linux:

nano /opt/tts/config/voice_to_speaker.yaml

Se você nunca usou o nano: ele abre o arquivo direto na tela, você digita como num bloco de notas, e as teclas de comando ficam listadas no rodapé. Para salvar e sair:

  • Ctrl+O (a letra O, de output) grava o arquivo
  • Enter confirma o nome
  • Ctrl+X fecha o editor

Os ^O e ^X do rodapé são esses mesmos comandos: o acento circunflexo quer dizer "Ctrl". E para colar no terminal o Ctrl+V comum não funciona: use Ctrl+Shift+V ou o botão direito do mouse. 📋

Dentro do arquivo, ache o bloco que começa com tts-1: e deixe-o assim:

tts-1:
  paloma:
    model: voices/pt_BR-faber-medium.onnx
    speaker: # default speaker
  alloy:
    model: voices/en_US-libritts_r-medium.onnx
    speaker: 79

O paloma é o apelido que eu escolhi para a voz, e é o nome que você vai escrever no campo voice do comando. Pode ser qualquer palavra: brasileira, joao, narrador.

E falta o passo que todo mundo esquece: editar o arquivo não muda nada sozinho. O programa leu a configuração quando subiu e não vai olhar de novo. Reinicie a caixinha:

docker restart tts

Agora sim, a mesma frase com a voz nova:

curl -X POST http://172.17.0.1:8000/v1/audio/speech \
  -H 'Content-Type: application/json' \
  -d '{"model":"tts-1","voice":"paloma","input":"Ola meus unicornios! Hoje vamos transformar texto em audio com inteligencia artificial local."}' \
  -o audio.mp3

3,8 segundos, e é aquele áudio que voltou quase perfeito do Whisper lá em cima. 🎉

Se você errar o nome da voz, o erro é limpinho e diz exatamente o que houve:

{
    "message": "Error loading voice: nao_existe, KeyError: 'nao_existe'",
    "code": 400,
    "type": "BadRequestError",
    "param": "voice"
}

Um 400 com o nome que você digitou dentro da mensagem. Quase sempre é erro de digitação, ou o docker restart que faltou.

🔗 Ligando ao Open WebUI (e fechando o ciclo)

Agora que o motor existe e o painel consegue falar com ele, falta contar ao Open WebUI que ele deve usá-lo. É o mesmo procedimento do artigo do Whisper, com a mesma pegadinha.

A configuração se muda com um POST, e ele exige o objeto completo: se você mandar só o pedacinho que quer trocar, a resposta é 422 reclamando dos campos que faltaram. O caminho que funciona é ler a configuração inteira, trocar o que precisa dentro do tts, e devolver tudo:

curl -s http://SEU_IP_AQUI:3000/api/v1/audio/config \
  -H "Authorization: Bearer sk-SUA_CHAVE_AQUI" > config.json

Abra o config.json no nano, ache o bloco "tts" e deixe estes cinco campos assim:

{
    "tts": {
        "ENGINE": "openai",
        "OPENAI_API_BASE_URL": "http://172.17.0.1:8000/v1",
        "OPENAI_API_KEY": "sk-local",
        "MODEL": "tts-1",
        "VOICE": "paloma"
    }
}

O único que causa espanto é o ENGINE valendo openai. Não se assuste: lembre que o motor local fala o protocolo da OpenAI. Você está dizendo ao Open WebUI "use o jeito de conversar da OpenAI", e logo abaixo apontando o endereço para dentro da sua própria máquina. Nada sai daqui. 🏠

O OPENAI_API_KEY é pelo mesmo motivo: o campo é obrigatório no formato, e o motor local não confere nada. Qualquer texto serve, e eu escrevi sk-local só para ficar óbvio na hora de reler.

Mande o arquivo de volta:

curl -X POST http://SEU_IP_AQUI:3000/api/v1/audio/config/update \
  -H "Authorization: Bearer sk-SUA_CHAVE_AQUI" \
  -H 'Content-Type: application/json' \
  -d @config.json

Aquele @ antes do nome quer dizer "os dados estão neste arquivo", em vez de digitados na linha.

E agora o momento que fecha o artigo. Lembra do 404 lá do começo? Mesmo comando, mesmo endereço, mesma chave:

curl -X POST http://SEU_IP_AQUI:3000/api/v1/audio/speech \
  -H "Authorization: Bearer sk-SUA_CHAVE_AQUI" \
  -H 'Content-Type: application/json' \
  -d '{"input":"O Open WebUI agora fala portugues com IA local."}' \
  -o webui.mp3
file webui.mp3
webui.mp3: Audio file with ID3 version 2.4.0, contains:
- MPEG ADTS, layer III, v2, 64 kbps, 22.05 kHz, Monaural

HTTP 200, em 4,3 segundos, e um MP3 de verdade no lugar do "não achamos o que você procura". 🎊 O mesmo endereço que não existia agora fala português, e o botão de alto-falante do painel passa a usar essa voz em vez da do navegador.

Se você esquecer a chave, o recado é outro:

{
    "detail": "Not authenticated"
}

Um 401. Repare que ele é bem diferente do 404 do começo: 401 é "eu existo, mas você não se identificou"; 404 era "eu não existo". 🔑

📊 Quanto isso consome (a surpresa boa)

Eu esperava que gerar voz fosse pesado. Medi com o docker stats, que mostra o consumo dos contêineres (o --no-stream tira uma foto em vez de ficar atualizando a tela):

docker stats --no-stream
NAME         MEM USAGE / LIMIT
tts          42.19MiB / 7.755GiB
open-webui   1.86GiB / 7.755GiB

42 MiB. 🤯 O motor de voz inteiro, em repouso, come menos memória que uma aba de navegador. É quarenta e cinco vezes menos que o painel do Open WebUI ao lado.

E a velocidade de geração é a parte que mais me impressionou:

Texto mandadoÁudio que saiuTempo para gerar
1 frase (~90 caracteres)~4 s de MP33,8 s
1 parágrafo (~300 caracteres)16,3 s de MP36,6 s

Olhe a segunda linha com atenção: um parágrafo virou 16,3 segundos de fala em 6,6 segundos de processamento. Isso quer dizer que ele gera mais rápido que o tempo real, e por uma boa margem: enquanto o áudio toca, a máquina já produziu quase três vezes aquilo. Numa VPS de 4 vCPU, sem placa de vídeo nenhuma. 🚀

É um contraste engraçado com os artigos anteriores da série. O modelo de conversa cospe 4,9 tokens por segundo e você sente a espera; o Whisper leva 9 segundos para transcrever 11 segundos de áudio. A voz é a peça mais leve das três, de longe.

No disco, as vozes somaram 196 MB com três modelos baixados. Quem pesa mesmo é a imagem do Docker, com 1,23 GB, e isso repete a lição do primeiro artigo: numa instalação dessas, o disco assusta mais que a memória. 💽

E fica o retrato da série inteira: a sua IA lê, ouve e agora fala, tudo dentro de um servidor que é seu. Nada do que você escreve, nada do que você grava e nada do que ela responde passa por empresa nenhuma. ✨

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

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

Perguntas frequentes

Por que o endpoint de áudio do Open WebUI responde 404?
Porque o Open WebUI não tem motor de voz de servidor embutido, ao contrário do Whisper. Com o campo tts.ENGINE vazio, quem lê o texto em voz alta é o navegador, não o servidor: por isso não existe endpoint nenhum para chamar por CURL, e a API devolve 404.
O meu texto vai para a nuvem?
Não. O motor é o Piper, que roda dentro de um contêiner na sua própria VPS, em CPU. A porta dele fica fechada para a internet, liberada só para a rede do Docker. O texto entra no seu servidor, vira MP3 lá dentro e não passa por empresa nenhuma no caminho.
Por que a voz lê o português com sotaque estranho?
Porque as vozes que vêm no contêiner (alloy, echo, fable, onyx, nova, shimmer) são todas en_US ou en_GB. Elas leem português como um estrangeiro leria. A correção é baixar uma voz pt_BR do repositório do Piper e registrá-la no voice_to_speaker.yaml.
Preciso de placa de vídeo para gerar áudio?
Não. Nesta VPS de 4 vCPU e 7,8 GB de RAM, sem GPU nenhuma, um parágrafo de 300 caracteres virou 16,3 segundos de fala em 6,6 segundos de processamento. Ou seja, gera mais rápido que o tempo real. O contêiner consome 42,19 MiB de RAM em repouso.
Troquei a voz no arquivo e nada mudou. Por quê?
Faltou reiniciar. O motor lê o voice_to_speaker.yaml quando sobe e não olha de novo sozinho: depois de editar, rode docker restart tts. Se o nome da voz estiver errado, a resposta é um 400 com o nome que você digitou dentro da mensagem de erro.
A porta do motor de voz fica exposta na internet?
Não, e isso é de propósito. O motor não tem senha nenhuma: quem alcança a porta 8000 gera áudio de graça no seu servidor. Publicando em 172.17.0.1:8000:8000, ele fica alcançável só pela rede do Docker, e de fora nem responde. É a mesma decisão do Ollama, no primeiro artigo da série.

Leia também