Post

Whisper.cpp local: uma API de transcrição sem depender da nuvem

Se preferir começar por um resumo, peça um TL;DR ao Claude .

Como expor o whisper.cpp em uma API local compatível com /audio/transcriptions e manter clientes como o OpenWhispr independentes da implementação.

Eu queria usar transcrição de voz no meu fluxo diário sem transformar cada áudio em uma chamada para um serviço externo. O requisito, porém, não era apenas rodar um modelo localmente. O cliente já trabalhava com o contrato de uma API de transcrição. Trocar o motor de inferência não deveria obrigar a trocar a interface, reescrever integrações ou ensinar um novo fluxo para quem já grava áudio.

Foi por isso que montei um serviço local com whisper.cpp que responde em /audio/transcriptions.

O resultado é uma separação simples:

1
2
3
4
Cliente de voz
    → API local de transcrição
        → conversão do áudio quando necessária
            → modelo Whisper executado localmente

O cliente envia o áudio. O serviço normaliza o formato quando preciso e devolve a transcrição. Para o cliente, o contrato continua sendo o mesmo; por trás dele, posso trocar o modelo, ajustar o número de threads ou mover o serviço de máquina sem alterar a integração.

Essa escolha fez sentido para um uso pessoal e interno, com volume previsível e um servidor que já estava disponível. Ela não é uma recomendação universal para todo produto que recebe áudio.

O problema não era instalar o Whisper

Executar o Whisper localmente é relativamente direto. O problema operacional aparece depois: o áudio gravado por navegadores e clientes de voz raramente chega no formato mais conveniente para a inferência.

Um teste local costuma começar com um WAV conhecido. Em produção, o serviço pode receber WEBM com Opus, MP3, M4A ou arquivos com metadados estranhos. Se o processo de transcrição aceita apenas um formato, a integração funciona no teste e falha quando alguém grava uma nota de voz real.

Eu precisava atender a quatro condições:

  • manter o áudio dentro da rede local;
  • aceitar os formatos que o cliente já produz;
  • não depender de GPU, Ollama ou uma conta de API;
  • preservar o endpoint que o cliente esperava.

O whisper.cpp atende bem ao último ponto porque inclui um servidor HTTP. Com --inference-path /audio/transcriptions, ele pode expor uma rota compatível com a interface de transcrição que muitas aplicações já conhecem.

A compatibilidade é mais valiosa do que parece

Uma API estável cria uma fronteira entre o produto e a infraestrutura de IA.

No cliente, o envio permanece parecido com isto:

1
2
3
4
curl -X POST http://stt.interno:8080/audio/transcriptions \
  -F file="@nota-de-voz.webm" \
  -F language="pt" \
  -F response_format="json"

Não importa se, no servidor, a implementação usa um binário em C++, uma GPU, um serviço em nuvem ou uma versão menor do modelo. O cliente precisa conhecer apenas o formato de entrada e a resposta esperada.

Essa separação é útil em três situações:

  • quando a equipe quer começar localmente e manter aberta a opção de usar outro backend depois;
  • quando o mesmo cliente precisa funcionar em ambientes com políticas de dados diferentes;
  • quando a parte cara ou mais experimental da infraestrutura muda mais rápido que o aplicativo.

No meu caso, ela também reduziu a fricção para usar o OpenWhispr. Em vez de adaptar o aplicativo a uma ferramenta específica de inferência, adaptei o serviço local ao contrato que o aplicativo já falava.

O detalhe que costuma quebrar a primeira versão: formatos de áudio

O whisper.cpp pode usar o FFmpeg para converter o áudio recebido antes da transcrição. Essa não é uma otimização opcional quando o cliente envia WEBM/Opus: é a diferença entre aceitar uma gravação do navegador e retornar um erro de decodificação.

Na compilação, habilitei o suporte ao FFmpeg:

1
2
3
4
5
6
7
8
9
sudo apt update
sudo apt install -y \
  git build-essential cmake ffmpeg \
  libavcodec-dev libavformat-dev libavutil-dev libswresample-dev pkg-config

git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp
cmake -B build -DWHISPER_FFMPEG=ON
cmake --build build -j

Antes de seguir, eu verificaria se a configuração realmente encontrou o FFmpeg:

1
rg 'WHISPER_FFMPEG' build/CMakeCache.txt

O resultado esperado é a opção habilitada. Compilar sem esse recurso e ativar --convert depois só desloca o problema para a primeira requisição real.

Também vale observar que as opções do servidor evoluem entre versões. Eu trataria o --help do binário instalado como a referência final para os parâmetros disponíveis, especialmente antes de automatizar a unidade do systemd.

Escolher o modelo é escolher um orçamento de latência e memória

Para esse serviço, usei o ggml-large-v3-turbo-q8_0.bin. O repositório de modelos convertidos para whisper.cpp o disponibiliza com cerca de 834 MiB, enquanto a variante não quantizada ocupa aproximadamente 1,5 GiB. A lista oficial de modelos mostra tamanhos e hashes para conferir o arquivo baixado.

1
2
3
4
mkdir -p models
curl -L \
  -o models/ggml-large-v3-turbo-q8_0.bin \
  https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin

Não escolhi esse arquivo porque ele seria “o melhor modelo”. Escolhi porque, para o áudio que eu transcrevia e a CPU disponível, ele oferecia qualidade suficiente sem exigir uma GPU dedicada.

Esse equilíbrio precisa ser medido no ambiente real. Eu começaria respondendo a estas perguntas:

  • Quanto tempo de áudio chega por requisição?
  • O usuário espera uma resposta interativa ou pode aguardar uma fila?
  • Quantas transcrições podem acontecer ao mesmo tempo?
  • Há memória suficiente para o modelo e para a conversão temporária dos arquivos?
  • O ganho de qualidade justifica aumentar a latência?

Um modelo maior pode melhorar casos difíceis. Mas, se uma máquina de CPU passa a acumular áudios, a experiência piora mesmo que a transcrição individual seja melhor. Para uso concorrente, eu avaliaria fila, limite de tamanho do upload, uma segunda instância ou aceleração por GPU antes de simplesmente aumentar o número de threads.

Um servidor pequeno, com responsabilidades explícitas

O comando de teste ficou próximo deste:

1
2
3
4
5
6
7
8
9
./build/bin/whisper-server \
  -m ./models/ggml-large-v3-turbo-q8_0.bin \
  --host 127.0.0.1 \
  --port 8080 \
  --convert \
  -ng \
  -l pt \
  --inference-path /audio/transcriptions \
  -t 4

Há algumas decisões embutidas aqui.

--convert delega ao FFmpeg o tratamento dos formatos que chegam do cliente. -ng desabilita GPU, coerente com a decisão de executar o serviço em CPU. -t 4 limita o trabalho a quatro threads; eu ajustaria esse número observando CPU, memória e a concorrência do host, não pela quantidade máxima de núcleos.

O endereço também merece atenção. Para um serviço publicado por um proxy reverso no mesmo host, eu prefiro 127.0.0.1. Assim, a API não fica exposta diretamente na rede. Quando o cliente precisa acessá-la de outra máquina, a alternativa não deveria ser apenas trocar para 0.0.0.0: é necessário definir firewall, autenticação e o ponto de entrada apropriado.

Transformar o comando em serviço

Depois que o teste manual funciona, o próximo passo é deixar o processo reiniciar sozinho e voltar depois de um reboot. Para um único serviço em um host pequeno, systemd é suficiente.

Eu criaria um usuário de serviço sem login e manteria binário, modelo e arquivos temporários em diretórios próprios. O exemplo abaixo usa caminhos genéricos justamente para evitar que o serviço dependa do diretório pessoal de alguém.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# /etc/systemd/system/whisper-server.service
[Unit]
Description=whisper.cpp local transcription API
After=network.target

[Service]
Type=simple
User=stt
Group=stt
WorkingDirectory=/opt/whisper.cpp
ExecStart=/opt/whisper.cpp/build/bin/whisper-server \
  -m /var/lib/whisper/models/ggml-large-v3-turbo-q8_0.bin \
  --host 127.0.0.1 \
  --port 8080 \
  --convert \
  -ng \
  -l pt \
  --inference-path /audio/transcriptions \
  -t 4
Restart=on-failure
RestartSec=5
Environment=OMP_NUM_THREADS=4

[Install]
WantedBy=multi-user.target

Em seguida:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable --now whisper-server
sudo systemctl status whisper-server

O ponto importante não é copiar a unidade literalmente. É tornar explícitos o usuário, o diretório de trabalho, o modelo carregado, a porta e o comportamento de reinício. Quando algum deles fica implícito, o serviço costuma funcionar até a próxima manutenção.

O teste que valida a integração

Eu não consideraria o serviço pronto apenas porque a porta responde. O teste precisa enviar um arquivo no mesmo formato que o cliente usa e verificar o formato de resposta que ele espera.

1
2
3
4
curl --fail-with-body -X POST http://127.0.0.1:8080/audio/transcriptions \
  -F file="@nota-de-voz.webm" \
  -F language="pt" \
  -F response_format="json"

Eu validaria pelo menos quatro coisas:

  • o servidor aceita WEBM/Opus, além de um WAV de laboratório;
  • a resposta contém o campo que o cliente lê;
  • uma gravação em português não está presa à configuração de inglês;
  • falhas de conversão aparecem nos logs com informação suficiente para diagnóstico.

Se o log disser que não foi possível decodificar o áudio, eu revisaria primeiro a compilação com FFmpeg e a disponibilidade do binário ffmpeg para o usuário do serviço. Esse foi o ponto de integração mais sensível da configuração.

Privacidade local não elimina responsabilidade operacional

Manter a inferência local evita enviar o áudio a um fornecedor externo, mas não torna o serviço automaticamente seguro.

Ainda existem arquivos temporários, logs, backups, permissões de diretório e uma porta HTTP para administrar. Se o áudio contiver informação sensível, eu decidiria conscientemente:

  • por quanto tempo arquivos temporários podem existir;
  • quem pode chamar o endpoint;
  • se o proxy precisa autenticar o cliente;
  • quais dados podem aparecer nos logs;
  • como os modelos e binários serão atualizados e verificados.

Em um ambiente compartilhado, eu evitaria expor a rota sem autenticação apenas porque ela está dentro da rede privada. Rede privada reduz a superfície; não substitui uma política de acesso.

Quando eu escolheria outra arquitetura

Essa abordagem é adequada para uma instância pequena, uso interno e carga que cabe no host. Ela começa a perder vantagem quando o requisito muda.

Eu procuraria outra solução quando fosse necessário:

  • processar muitos áudios simultâneos com latência previsível;
  • usar GPU para reduzir o tempo de transcrição;
  • escalar horizontalmente e distribuir filas;
  • manter alta disponibilidade entre máquinas;
  • oferecer o serviço como produto público com controle de consumo;
  • aplicar diarização, pós-processamento ou retenção de artefatos mais complexa.

Nessas situações, o endpoint compatível continua útil. A implementação atrás dele pode migrar para uma fila, um pool de workers ou outro backend sem obrigar os clientes a reaprender o contrato.

Conclusão

O ganho principal dessa configuração não foi apenas transcrever áudio sem uma API externa. Foi manter a transcrição como uma capacidade local com uma interface estável.

Para um cenário de baixo a médio volume, eu começaria com uma única instância de whisper.cpp, conversão via FFmpeg e uma rota compatível com /audio/transcriptions. Mediria qualidade, tempo de resposta e uso de CPU com áudios reais. Só então decidiria se vale investir em GPU, fila ou múltiplos workers.

O cliente continua falando com uma API de transcrição. A infraestrutura por trás dela pode evoluir no ritmo que o ambiente permitir.