Pare de Pagar por Silêncio: Silence Trim para STT na Cloud e Idle Unload do Whisper Local no Hermes Agent

As mensagens de voz são a forma mais natural de interagir nas plataformas de mensagens do Hermes Agent — carregue no botão, fale e a transcrição chega ao agente. Mas se usar um fornecedor de speech-to-text (STT) na cloud, cada nota de voz custa-lhe silenciosamente duas vezes: uma delas pelo silêncio.
Uma nota de voz de 13 segundos pode conter apenas 6 segundos de fala real — os restantes 7 segundos são o tempo em que está a pensar. O Whisper na cloud cobra por minuto de áudio e o silêncio é cobrado à mesma taxa que a fala (a OpenAI cobra $0.006/min por isso). Pior: o Whisper alucina palavras nos trechos de silêncio — é por isso que as transcrições na cloud contêm ocasionalmente frases que nunca foram ditas.
Dois PRs do Hermes Agent recentemente fundidos resolvem exatamente estes dois problemas, ambos essencialmente plug-and-play (um ativo por defeito, o outro uma única linha de configuração):
- #77581 — Silence trim pré-upload para STT na cloud: colapsa pausas longas com ffmpeg antes do upload. Resultado medido: uma nota de voz de 13.2s reduzida para 6.2s (-53%) com uma transcrição totalmente equivalente.
- #81027 — Idle unload para o modelo Whisper local: descarrega automaticamente o modelo local após 5 minutos de inatividade, libertando ~370MB de RAM/VRAM, e recarrega-o de forma transparente na próxima mensagem de voz.
Contexto: duas rotas de STT, local e cloud
Antes das novas funcionalidades, eis a arquitetura. O STT vive na secção stt do config.yaml; o provider escolhe a rota:
stt:
enabled: true
provider: local # local | groq | openai | mistral | xai | elevenlabs | deepinfra
language: "en" # global language hint, avoids wrong-language detection on short clips
Rota local (local): o faster-whisper corre na sua própria máquina — gratuito e privado, mas o modelo permanece residente em memória. Um hardening anterior já tinha adicionado o Silero VAD (voice activity detection), pelo que o silêncio nunca chega ao modelo.
Rota cloud (groq/openai/mistral/xai/elevenlabs/deepinfra): o áudio é enviado em bruto para uma API de terceiros e cobrado por minuto de áudio. O problema: a rota cloud nunca teve proteção equivalente ao VAD — o áudio em bruto, pausas incluídas, subia intacto.
Os dois PRs fecham exatamente essas lacunas: silence trim para a rota cloud, idle unload para a rota local.
Parte 1 — Silence trim na cloud: colapsar as pausas antes do upload
Como ativar
Está ativo por defeito — três definições:
stt:
cloud_trim_silence: true # false = always upload the original audio
cloud_trim_threshold_db: -40 # audio quieter than this counts as silence
cloud_trim_keep_ms: 300 # keep 300ms of each pause, preserving word boundaries and pacing
O fluxo: antes do upload, qualquer clip com mais de 12 segundos tem as suas pausas longas colapsadas em intervalos de 300ms através do filtro silenceremove do ffmpeg (áudio abaixo de -40dB conta como silêncio) e, depois, a versão reduzida é enviada. O ffmpeg já era uma dependência deste mesmo caminho de código (transcodificação CAF), pelo que não há novas dependências.
Números medidos (execução real pelo autor do PR)
original: 13.15 s (speech 3s + pause 7s + speech 3s)
INFO Trimmed silence from voicenote.wav before cloud STT upload (13.2s -> 6.2s, -53%)
trimmed: 6.24 s
O áudio reduzido foi verificado com o faster-whisper: ambas as falas transcrevem de forma idêntica ao original — pausas eliminadas, fala intacta.
O gate de 12 segundos: porque é que os clips curtos saltam o trim
Tudo o que está acima diz «clips com mais de 12s». Esse gate é um controlo de custos deliberado: os clips curtos levam um ffprobe de ~50ms e saltam a codificação. O raciocínio é concreto — num clip abaixo de 12s a poupança máxima possível é de ~10%, cerca de 1 segundo de áudio, e vários fornecedores cobram na mesma um mínimo por pedido (a Groq cobra um mínimo de 10s). A codificação nunca se paga a si própria em clips curtos; só os clips suficientemente longos para beneficiarem de forma plausível pagam o custo da codificação.
Estritamente best-effort: o trim nunca pode quebrar a transcrição
Este é o núcleo do design: o trim é best-effort — todos os modos de falha enviam o original intacto, e a transcrição nunca falha por causa do trim:
| Condition | Behavior |
|---|---|
cloud_trim_silence: false |
Original uploads |
| ffmpeg/ffprobe missing | Original uploads |
| Clip shorter than 12s | Original uploads (one probe, no encode) |
| Trim command fails / times out | Original uploads |
| Trimmed result ~empty (mostly-silence clip) | Original uploads — the provider, not a client-side dB heuristic, decides whether it contains speech |
| Trim saves <10% | Original uploads |
As duas últimas linhas merecem uma segunda leitura: decidir se uma gravação quase silenciosa «contém fala» fica a cargo do fornecedor (que tem a sua própria deteção de voz), e re-codificar para uma poupança de <10% é puro desperdício — por isso o trim simplesmente desiste.
Quando desativar
O limiar de -40dB trata ambientes silenciosos como silêncio. Se as suas mensagens de voz forem muitas vezes música, som ambiente ou ruído branco em vez de fala (por exemplo, pedir ao agente para identificar uma música ou uma gravação de campo), defina cloud_trim_silence: false para repor os uploads em bruto — a mesma filosofia do vad: false na rota local.
Parte 2 — Idle unload do Whisper local: o leak de 370MB de que não se apercebeu
O problema: carregar uma vez, reter para sempre
O modelo local faster-whisper é um singleton: carrega na primeira mensagem de voz e nunca é libertado — durante toda a vida do processo. O modelo base retém cerca de 370MB, mesmo que não chegue nenhuma mensagem de voz durante horas ou dias.
É especialmente dispendioso em processos de gateway do Hermes de longa duração — em particular em máquinas onde o Whisper compete com um LLM local pela mesma GPU: a VRAM que o Whisper reserva é VRAM que o seu modelo local não pode usar.
Como ativar
stt:
local:
model: "base" # tiny | base | small | medium | large-v3
unload_after_idle_seconds: 300 # 0 = never unload (default); 300 = unload after 5 idle minutes
O valor por defeito 0 significa nunca descarregar — zero mudanças de comportamento para utilizadores existentes. Defina-o para 300 (recomendado para setups de gateway) e:
- Uma thread watcher verifica a cada 30 segundos; quando o tempo de inatividade excede o limiar, a referência ao modelo é largada para o GC
- A próxima mensagem de voz recarrega-o de forma transparente através do caminho lazy-load existente — sem diferença visível para o utilizador, exceto um atraso de carregamento do modelo
- A configuração é relida a cada ciclo: edite o valor no
config.yamle entra em vigor dentro de um intervalo de verificação — sem reiniciar o processo; voltar a definir 0 a meio da inatividade até cancela o unload pendente
Os números honestos de memória: GPU vs CPU
O autor do PR mediu ambos honestamente e vale a pena citar:
- CUDA/GPU: depois de o objeto do modelo ser recolhido pelo GC, a VRAM é devolvida ao dispositivo — a grande vitória para setups que partilham GPU.
- CPU (medido em macOS): as referências Python são largadas e a memória torna-se reutilizável, mas o allocator C++ do ctranslate2 não devolve páginas ao SO, pelo que o RSS mal se mexe (medido: 388MB antes e depois). Em Linux, o comportamento do
malloc_trimdo glibc pode devolver algumas.
Em termos simples: em GPU é uma libertação real; em CPU torna a memória principalmente reutilizável — o modelo deixa de reter centenas de MB de objetos vivos, e um modelo de tamanho diferente configurado mais tarde carrega no espaço recuperado em vez de fazer crescer o processo. De qualquer forma, deixou de ser «carregar uma vez, reter para sempre».
Parte 3 — Configurações recomendadas por caso de uso
| Scenario | Recommendation |
|---|---|
| Long-running gateway, local LLM on the same GPU | local.unload_after_idle_seconds: 300 (strongly recommended — VRAM is truly freed) |
| Desktop/CLI, occasional voice, tight RAM | local.unload_after_idle_seconds: 600 — unload after 10 idle minutes |
| Frequent voice messages, latency-sensitive | Keep 0 — avoid the model-load wait on the first message |
| Cloud STT (groq/openai, etc.) | Keep cloud_trim_silence: true (default) — costs drop immediately |
| Voice messages are often music/ambient | cloud_trim_silence: false |
Depois de editar, abra o ficheiro com hermes config edit ou verifique os valores atuais com hermes config get stt.local.unload_after_idle_seconds.
Quatro dicas práticas
- STT na cloud + comandos de voz curtos é a melhor combinação: o gate de 12s significa que comandos comuns («ver o tempo de amanhã») nunca acionam o trim nem pagam o custo da codificação — o trim só entra em ação para notas de voz longas.
- Não salte a dica de idioma: um
stt.language: "en"global aplica-se também aos fornecedores cloud (a configuração por fornecedor vence), e os comandos de voz curtos falham frequentemente porque a auto-deteção do Whisper adivinha o idioma errado. - Verifique antes de mudar:
hermes config get stt.cloud_trim_silencemostra diretamente o valor efetivo; corrahermes config checkdepois das edições para validar a sintaxe. - Local e cloud são intercambiáveis conforme a necessidade: mude o campo
providera qualquer momento. Quer custo zero?local(gratuito mas residente em memória — combine-o com o idle unload). Quer multilíngue de alta precisão? Rota cloud — e o silence trim mantém a fatura controlada.
Resumo
Em conjunto, os dois PRs arrumam ambas as rotas de STT: a rota cloud deixa de pagar por pausas ou de ver as suas transcrições poluídas por alucinações de silêncio; a rota local deixa de reter 370MB de RAM/VRAM parados. Configurações pequenas, zero dependências novas, ganhos puramente incrementais — os utilizadores com uso intensivo de voz (especialmente setups residentes em gateway + STT na cloud) deviam ativar isto hoje.
Para aprofundar as capacidades de voz do Hermes Agent e a configuração relacionada, consulte as notas de lançamento do v0.19.1 Voice Patch, o guia de instalação e as notas de lançamento do v0.20.0 Herald.