Executando verificação de segurança...
32

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:

  1. gpu layers → 0 (mais comum no macos)
  2. context size → 2048
  3. batch size → 128
  4. 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)

modelotamanhoramuso
gemma-2-2b-it1.7 GB~4 GBdefault, leve
llama-3.2-3b1.9 GB~4 GBalternativa
mistral-7b4.1 GB~8 GBqualidade
llama-3.1-8b4.6 GB~10 GBmáxima

llm com visão (gguf + mmproj)

modelotamanho totalramuso
qwen3-vl-2b1.5 GB~6 GBeconomia
gemma-3-4b3.3 GB~8 GBrecomendado
gemma-3-12b8.0 GB~16 GBmáxima

whisper (ggml)

modelotamanhoramvelocidade
tiny77 MB~390 MBmuito rápido
base142 MB~500 MBrápido
small466 MB~1 GBmédio
medium1.5 GB~2.6 GBlento

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.

Carregando publicação patrocinada...
4
2

Tenho um notebook com placa de vídeo que está sem uso pq a saída de vídeo está com defeito. Estou querendo montar um server para rodar algumas LLMS em rede interna.

2

aparentemente vai ser uma nova tendência os sistemas integrados com ia rodarem pequenos modelos rodando local e na máquina do client, já é possível rodar modelos sem depender de um backend, eu vi no youtube do micael mota a biblioteca transformers.js que utiliza web gpu possibilitando usar o hardware do client para processar dados, claro que depende da máquina do client suportar e o navegador também, mas eu acho que as coisas vão começar a caminhar bastante para esse lado

1