rodando IA local no Perssua: um deep dive técnico
finalmente terminei de plugar o perssua pra rodar com modelos locais.
transcrição via whisper.cpp com modelos ggml
análise e visão via llama-server local com mdelos gguf.
o app ainda fala "estilo openai" em json mas cada vez mais a inteligência roda na sua máquina.
a arquitetura geral fica mais ou menos assim:
flowchart TB
subgraph renderer["processo renderer (chromium)"]
react[react ui]
localProvider[LocalProvider.js]
whisperClient[LocalWhisperRealtime.js]
end
subgraph ipc["ponte ipc"]
electronAPI[window.electronAPI]
end
subgraph main["processo main (node.js)"]
llmService[LocalLLMService]
whisperService[LocalWhisperService]
modelMgr[ModelManagers]
end
subgraph native["binários nativos"]
llamaServer["llama-server<br/>porta 52845-52855"]
whisperCli["whisper-cli"]
end
subgraph storage["disco"]
gguf[(modelos gguf)]
ggml[(modelos ggml)]
end
react --> localProvider
react --> whisperClient
localProvider --> electronAPI
whisperClient --> electronAPI
electronAPI --> llmService
electronAPI --> whisperService
llmService --> llamaServer
whisperService --> whisperCli
modelMgr --> gguf
modelMgr --> ggml
dois processos nativos rodando em background.
llama-server escuta numa porta alta.
whisper-cli é invocado por spawn a cada chunk.
tudo isolado do chromium sandbox.
whisper: do microfone ao texto
captura de áudio
flowchart LR
mic[microfone] --> mediaStream
mediaStream --> audioContext["AudioContext<br/>16kHz"]
audioContext --> analyser["AnalyserNode"]
analyser --> scriptProcessor["ScriptProcessor<br/>2048 samples"]
scriptProcessor --> pcm16["PCM16 Int16Array"]
pcm16 --> base64["base64 encode"]
base64 --> ipc["IPC → main process"]
áudio entra como float32 (-1.0 a 1.0).
converto pra pcm16 antes de mandar.
// conversão float32 → int16
for (let i = 0; i < float32.length; i++) {
const clamped = Math.max(-1, Math.min(1, float32[i]));
int16[i] = Math.round(clamped * 32767);
}
sample rate fixo em 16kHz.
whisper.cpp espera esse formato.
qualquer outra coisa e ele reclama ou distorce.
sliding window
não dá pra jogar todo áudio pro whisper de uma vez.
memória estoura, latência explode.
então uso "sliding window".
sequenceDiagram
participant buf as RingBuffer
participant vad as VAD
participant whisper as whisper-cli
loop a cada chunk (~128ms)
buf->>buf: append audio
buf->>vad: calcula RMS
alt RMS > 500
vad->>vad: speechDetected = true
else RMS < 500
vad->>vad: silenceCount++
end
end
alt buffer >= 3s OU (buffer >= 6s E fala detectada)
buf->>whisper: extrai janela 3s
whisper->>buf: transcrição parcial
end
alt silenceCount > 500ms após fala
buf->>whisper: flush final
whisper->>buf: utterance completo
end
janelas de 3 segundos.
overlap de 0.5 segundo.
assim palavras não são cortadas no meio.
vad simples via RMS.
threshold 500.
se tiver silêncio, não gasto ciclo.
quando a fala para, flush imediato.
última frase nunca some.
deduplicação
overlap gera duplicatas.
"hello world" vira "hello world world".
então comparo sufixo da última transcrição com prefixo da nova.
se bater, corto o overlap.
// pseudo-código de dedupe
const overlap = findOverlap(lastTranscription, newTranscription);
if (overlap.length > 3) {
newTranscription = newTranscription.slice(overlap.length);
}
buffer cap
máximo 20 segundos no buffer.
proteção contra fala infinita.
se passar, descarta o mais antigo.
memória nunca explode.
timeout
whisper pode travar em áudio difícil.
timeout = 15s + (10s × segundos de áudio).
se passar, kill -9 no processo.
próxima janela tenta de novo.
macos vs windows: whisper
macos (metal)
gpu via metal é rápido.
mas metal no macos tem um problema.
se o processo crashar, ele pode não morrer limpo.
gpu fica num estado zuado.
próxima execução crasheia de novo.
solução: marker file.
antes de rodar com gpu, crio marker em ~/.strawberry/.whisper_gpu_unsafe.
se whisper terminar limpo, removo.
se crashear, marker fica.
próxima vez detecto e rodo só cpu.
// LocalWhisperService.js
const MARKER = path.join(app.getPath('userData'), '.whisper_gpu_unsafe');
// antes de spawn com gpu
fs.writeFileSync(MARKER, Date.now().toString());
// depois de sucesso
fs.unlinkSync(MARKER);
// na inicialização
if (fs.existsSync(MARKER)) {
// gpu crashou da última vez, desabilita
this.gpuEnabled = false;
}
override via env var STRAWBERRY_WHISPER_IGNORE_UNSAFE_GPU_MARKER=1 pra quem quiser forçar.
binários por arquitetura
macos tem dois mundos: apple silicon e intel.
electron/native/whisper/bin/
├── darwin-arm64/
│ └── whisper-cli # apple silicon
└── darwin-x64/
└── whisper-cli # intel
apple silicon compila local com metal.
intel vem do CI, metal off por default.
mesmo código, chips diferentes.
windows
sem metal.
gpu via vulkan ou cuda se disponível.
maioria roda cpu mesmo.
windows não tem o problema do marker.
processos morrem mais limpo.
llm local: llama-server
ciclo de vida
stateDiagram
[*] --> idle
idle --> starting: requisição de inferência
starting --> spawning: binário + modelo ok
spawning --> waitingHealth: processo up
waitingHealth --> running: /health 200
waitingHealth --> autoTune: OOM ou timeout
autoTune --> spawning: config ajustada
autoTune --> error: tentativas esgotadas
running --> running: processando
running --> idleTimeout: 10min sem uso
idleTimeout --> stopping
stopping --> idle
error --> idle
servidor só sobe quando precisa.
primeira requisição trigga spawn.
depois fica rodando.
10 minutos sem uso = auto shutdown.
nada rodando em background à toa.
porta dinâmica
const PORT_START = 52845;
const PORT_END = 52855;
for (let port = PORT_START; port <= PORT_END; port++) {
if (await isPortFree(port)) return port;
}
throw new Error('sem porta disponível');
range alto pra evitar conflito.
testa uma por uma até achar livre.
argumentos do llama-server
llama-server \
-m /path/to/model.gguf \
--port 52845 \
--host 127.0.0.1 \
-c 4096 \ # context size
--threads 6 \ # cpu threads
-b 256 \ # batch size
-ub 64 \ # micro batch
-np 1 \ # parallelism
-ngl 0 # gpu layers (metal)
cada flag afeta memória e velocidade.
errar aqui e o processo morre de OOM.
auto-tuning
não dá pra saber o hardware do usuário.
então tento e ajusto.
flowchart TD
attempt1["tentativa 1<br/>config otimista"] --> check1{OOM?}
check1 -->|não| success[salvar config]
check1 -->|sim| attempt2["tentativa 2<br/>-ngl 0 (sem gpu)"]
attempt2 --> check2{OOM?}
check2 -->|não| success
check2 -->|sim| attempt3["tentativa 3<br/>-c 2048 (menos contexto)"]
attempt3 --> check3{OOM?}
check3 -->|não| success
check3 -->|sim| attempt4["tentativa 4<br/>-b 128 (batch menor)"]
attempt4 --> check4{OOM?}
check4 -->|não| success
check4 -->|sim| fail[erro: hardware insuficiente]
success --> localStorage[(salvar pra próxima vez)]
ordem de redução:
- gpu layers → 0 (mais comum no macos)
- context size → 2048
- batch size → 128
- micro batch → 32
config que funcionou fica salva em localStorage.
próxima vez já sabe o que usar.
// estrutura do tuning salvo
{
model: "gemma-2-2b-it",
ctxSize: 2048,
gpuLayers: 0,
batchSize: 128,
autoTuned: true,
attempts: 3
}
macos vs windows: llama-server
macos (metal)
gpu layers = quantas camadas vão pra gpu via metal.
metal é rápido, mas consome vram que o macos compartilha com ram.
modelo de 2B com -ngl 99: até 4GB de vram.
se não tiver, OOM.
default no strawberry: -ngl 0 no macos.
metal é bom, mas metal + electron + chromium brigam por gpu.
ui trava se metal comer tudo.
usuário pode aumentar nas configs se tiver ram sobrando.
windows
sem metal.
cuda se tiver nvidia.
vulkan como fallback.
maioria roda cpu.
windows gerencia memória diferente, OOM menos comum.
mas mais lento sem gpu.
binários por plataforma
electron/native/llama/bin/
├── darwin-arm64/
│ ├── llama-server
│ └── *.dylib # metal framework
├── darwin-x64/
│ ├── llama-server
│ └── *.dylib
├── win32-x64/
│ └── llama-server.exe
└── linux-x64/
└── llama-server
cada plataforma tem seu binário.
dylibs do metal vão junto no macos.
windows é self-contained.
visão: screenshots pro llm
modelos com vision adapter (gemma-3, qwen3-vl) processam imagens.
flowchart LR
screenshot[captura tela] --> resize["resize<br/>(max 1024px)"]
resize --> base64[base64 encode]
base64 --> request["POST /v1/chat/completions"]
request --> llama["llama-server<br/>com mmproj"]
screenshot entra como base64.
llama-server precisa do mmproj (multimodal projector) carregado.
dois arquivos: modelo principal + mmproj.
llama-server \
-m gemma-3-4b-it.gguf \
--mmproj gemma-3-4b-it-mmproj.gguf \
--image-max-tokens 4096
imagem grande = muitos tokens.
resize antes de mandar economiza contexto.
se ainda estourar, retry com imagem menor.
provider abstraction
classDiagram
class BaseProvider {
<<abstract>>
+callSummaryAPI()
+parseDelta()
}
class LocalProvider {
+requiresApiKey: false
+callSummaryAPI()
}
class OpenAIProvider {
+requiresApiKey: true
+callSummaryAPI()
}
BaseProvider <|-- LocalProvider
BaseProvider <|-- OpenAIProvider
local provider não pede api key.
mesma interface, backend diferente.
ui não sabe se tá falando com gpt-4 ou gemma local.
// LocalProvider.js
async callSummaryAPI(messages, opts) {
return await window.electronAPI.localLLM.chatCompletion({
messages,
maxTokens: opts.maxTokens || 1000,
temperature: opts.temperature || 0.7
});
}
request vai pro localhost.
resposta volta em formato openai.
streaming funciona igual.
modelos disponíveis
llm (gguf)
| modelo | tamanho | ram | uso |
|---|---|---|---|
| gemma-2-2b-it | 1.7 GB | ~4 GB | default, leve |
| llama-3.2-3b | 1.9 GB | ~4 GB | alternativa |
| mistral-7b | 4.1 GB | ~8 GB | qualidade |
| llama-3.1-8b | 4.6 GB | ~10 GB | máxima |
llm com visão (gguf + mmproj)
| modelo | tamanho total | ram | uso |
|---|---|---|---|
| qwen3-vl-2b | 1.5 GB | ~6 GB | economia |
| gemma-3-4b | 3.3 GB | ~8 GB | recomendado |
| gemma-3-12b | 8.0 GB | ~16 GB | máxima |
whisper (ggml)
| modelo | tamanho | ram | velocidade |
|---|---|---|---|
| tiny | 77 MB | ~390 MB | muito rápido |
| base | 142 MB | ~500 MB | rápido |
| small | 466 MB | ~1 GB | médio |
| medium | 1.5 GB | ~2.6 GB | lento |
todos em Q4_K_M quantization.
melhor ratio qualidade/tamanho.
hosted no huggingface.
proteções
contra oom
- auto-tuning com fallback
- buffer cap no whisper (20s)
- timeout em spawns
- retry com config menor
contra crash de gpu
- marker file no macos
- fallback automático pra cpu
- gpu serializado entre sessões
contra travamento
- idle timeout (10min)
- kill -9 se timeout de health check
- cleanup em beforeunload
contra perda de dados
- flush forçado quando fala para
- partial deltas durante transcrição
- nenhum áudio descartado sem processar
por que não só cloud?
porque o ponto do Perssua é:
inteligência local primeiro.
cloud como opção, não requisito.
menos dados saindo da máquina.
sem surpresa de token bill.
e a satisfação específica de ver seu próprio hardware acender quando seu assistente "pensa".
tudo local.
tudo seu.