Um mergulho de engenharia no DuxBot K1: do socket do microfone ao tool-call do LLM, da segurança anti-queda em domínio puro ao deploy em docker-compose num Jetson de 8 GB.
Este é o documento técnico longo. Se você quer a versão narrativa, ela existe em outro lugar. Aqui o objetivo é diferente: abrir o capô e mostrar como cada subsistema do DuxBot — o cérebro de voz do robô humanoide Booster K1 Education — é construído, por que cada decisão foi tomada, e quais armadilhas custaram horas de campo até virarem regra.
O hardware: K1 Education, 22 graus de liberdade, Jetson Orin NX (aarch64, 8 GB de RAM unificada CPU+GPU, 6 núcleos, kernel RT-Tegra), Ubuntu 22.04. Um robô que ouve, conversa, pesquisa na web, dança, chuta, levanta do chão e reconhece quem está na frente dele.
Vamos da borda física para dentro.

Todo o agente é Go, em arquitetura hexagonal (Ports & Adapters). A regra é só uma e é absoluta: a dependência sempre aponta para dentro.
adapters/ ──► ports/ ──► domain/
(I/O, SDKs) (interfaces) (stdlib pura)
domain/ lógica pura, ZERO deps externas (só stdlib)
command.go enum Action, superfície de Tool, Choreography(action,args) → []Step
safety.go SSOT anti-queda: SafeMode/ClampLinear/ClampYaw/IsSafeTrajectory
segment.go VADSegmenter — máquina de estados de fala por energia (sem bytes)
conversation.go ConversationGuard — half-duplex, double-talk, echo-guard, reset
wake.go WakeGate — máquina sleep/wake "hey duxbot", tempo injetado
guardrails.go ScreenInput/ScreenOutput — filtro de palavrão/código (robô fala com crianças)
ports/ interfaces: AudioCapture, AudioPlayback, RealtimeLLM/Session,
RobotController, Transcriber/StreamTranscriber, WakeDetector,
PerceptionInput, VisionInput, IdentityInput, EventInfo, FleetRegistry...
adapters/
driven/ backends plugáveis (LLM, STT, áudio, robô, percepção, RAG...)
driving/ orquestradores (voiceloop, webremote)
sidecar/ C++ sobre unix socket (robot_bridge, audio_bridge)
cmd/ 11 entrypoints — duxbot, duxvoice, duxchat, duxbench, admin...
Por que tanto rigor? Porque o domain/ carrega a lógica
que não pode falhar — sobretudo a segurança física — e
por importar apenas a biblioteca-padrão, ele é testável a ~98%
de cobertura em milissegundos, sem hardware, sem GPU, sem rede.
A física de queda do robô passa por go test.
Há um detalhe de build que parece trivial e não é:
GOWORK=off é obrigatório. O módulo
duxbot vive fora do go.work da raiz do
workspace; sem essa variável, o go build resolve contra o
workspace errado e falha. E o cross-compile para o robô é
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 — ELF estático,
zero dependências de runtime no Jetson.
O SDK da Booster é uma biblioteca estática C++
(libbooster_robotics_sdk.a) com binding Python, mas
sem binding Go. Para manter o agente Go puro
(cross-compila no Mac, sem cgo), o controle do robô vive num
sidecar C++ minúsculo compilado no próprio robô. A
comunicação Go ↔︎ sidecar é um unix socket com JSON
delimitado por newline.
São, na verdade, dois sidecars:
robot_bridge.cpp — detém o
B1LocoClient (locomoção) em
/tmp/duxbot_robot.sock. Protocolo de fio, uma linha entra,
uma sai:
{"op":"change_mode","mode":2} → {"ok":true}
{"op":"move","vx":0.3,"vy":0,"vyaw":0} → {"ok":true}
{"op":"wave_hand","hand":1,"action":0} → {"ok":true}
{"op":"replay_trajectory","path":"..."} → {"ok":false,"error":"..."}audio_bridge.cpp — o áudio NAEC:
microfone com cancelamento de eco, player de 24 kHz, e o flush
de barge-in. Três FIFOs:
/tmp/duxbot_cap — mic PCM16 16k (sidecar → Go)/tmp/duxbot_play — playback PCM16 24k (Go →
sidecar)/tmp/duxbot_audio_ctl — um único byte 'F'
= flush (corta o som enfileirado na hora)Um detalhe que economiza horas de debugging: os FIFOs são abertos
O_RDWR (leitura e escrita) para que o
open() nunca bloqueie esperando um peer e as
leituras não deem EOF quando o bridge cicla. É o mesmo truque que o
código Python original usava.
E uma jóia de observabilidade barata: o Go carimba o header W3C
traceparent do span ativo em cada linha de op. O sidecar
C++ não fala OpenTelemetry — ele só extrai o
trace_id (32 hex) e o ecoa no stderr. Resultado:
grep num trace_id correlaciona o journal do
sidecar com o trace no Jaeger, sem arrastar a dependência pesada do OTel
para o C++.

O microfone é um array USB único (card
Speaker, capture node /dev/snd/pcmC1D0c). O
fluxo:
mic USB → booster-audio (NAEC, abre hw:1,0 EXCLUSIVO) → audio_bridge → /tmp/duxbot_cap → duxbot-go
O sintoma mais recorrente do projeto foi a surdez:
“falo e ele fica mudo”. O agente fala normal, mas não escuta; o
peakRMS no log fica ~5 mesmo gritando; o FIFO
/tmp/duxbot_cap fica em 0 byte.
A investigação foi um caso de escola, porque a causa parecia óbvia e era um red herring:
Suspeito errado: o booster-audio
faz, no boot, um POST a um servidor de licença
(authority.huwentec.com) para o backend AEC. Parecia o
gargalo. Mas: falhou 98 de 98 vezes no histórico
e o mic funcionava — a auth nunca foi pré-requisito de
captura. O arquivo de licença nem existe no disco. Cachear a auth não
resolveria nada.
Causa raiz real (provada em campo):
contenção de device. Dois processos booster-audio
brigando pelo microfone USB único. Quem perde pega
snd_pcm_open ret=-16 (Device or resource busy) → o backend
AEC entra em DEGRADED → responde
2001 "audio backend is unavailable" para sempre → surdez.
As duas origens do segundo processo: um spawner rogue de um
script de recovery antigo (lançava
setsid nohup booster-audio fora do systemd), e um SEGV no
boot quando dezenas de serviços sobem juntos.
A cura foi em três camadas de defesa: eliminar o
spawner rogue; um bring-up de áudio de fonte única
(audio-up-coordinated.sh) que garante dono
único do device antes de subir (mata rogue, confirma o device
livre com fuser, espera o
backend init success, e só então sobe os consumidores na
ordem áudio→controle→go); e um watchdog de host que
detecta a ausência de heartbeat de captura e roda a mesma rotina
sozinho, cobrindo boot e runtime sem operador.
Lição transferível: o sinal autoritativo de “pronto” é o journal da unit (
backend init success), não a auth nem os bytes do FIFO durante a subida. Quando um sistema tem uma dependência ruidosa (a auth que sempre falha), é fácil culpar o ruidoso. Medir o que de fato gate o recurso é o que fecha o caso.
Conversa natural exige barge-in: o usuário interrompe e o robô para na hora. Há dois modos, e a diferença é instrutiva.
Full-duplex (robusto, recomendado). O mic flui
sempre para o LLM realtime; o VAD do servidor ouve a fala por cima do
bot e dispara um abort total (cancela a resposta, descarta o
áudio pendente, player.Stop em ~1–2 ms). O truque fino é o
gate de over-talk dinâmico: ele rastreia uma média
móvel (EMA) do piso de ruído/eco ambiente enquanto o bot fala, e só
encaminha frames que saltam um fator K (default 3,0) acima
desse piso. Isso auto-calibra entre ambientes: fone no
Mac (ambiente ~150–300 → threshold ~750) e robô (eco NAEC ~5–9k →
threshold cap em 11000), sem ajuste manual.
Há uma sutileza de UX escondida aí: ao confirmar a interrupção, abre-se uma janela “sticky” de 0,4 s e o áudio flui contínuo. Por quê? A fala humana normal tem micro-quedas entre sílabas que picotariam o fluxo, e o VAD do servidor nunca veria fala contígua. A janela cobre o tempo até o VAD disparar.
Half-duplex + DTD (fallback). Enquanto o bot fala, o mic fica mudo; um double-talk detector compara o RMS do mic mudo contra o eco do playback. Heurística frágil — quase nunca dispara no Mac sem AEC nativo. O full-duplex é a escolha.
turn.goComo o robô faz, no mesmo turno, pesquisar + conversar + se mexer? A resposta está num laço aparentemente simples e numa divisão de trabalho rígida.
| Capacidade | Quem executa | Mecanismo |
|---|---|---|
| Conversa | brain (nuvem) | streaming de texto |
Pesquisa (search_web) |
inline no turn.go |
resultado vira mensagem role:tool no histórico |
| Movimento / identidade | delegado ao voiceloop |
evento → executor + safety SSOT |
A pesquisa é inline porque é só texto — volta para o histórico e o
brain segue raciocinando. O movimento é delegado porque
quem detém o robô físico e a SSOT anti-queda é o voiceloop;
o turn.go nunca toca no metal — ele emite
um evento e bloqueia num canal até o corpo terminar.
Busca e movimento nunca se atropelam.
A razão estrutural de os três caberem num turno é um laço de hops:
for hop := 0; hop < 6; hop++ {
// chama o brain 1×: devolve TEXTO (fim) OU tool-calls
// re-chama o brain com os resultados das tools
}Cada hop chama o brain uma vez. O brain devolve texto (fim) ou
tool-calls. Como o laço re-alimenta o brain com os resultados, um turno
encadeia: search_web (hop 0) → resultado injetado →
wave + fala (hop 1) → fim (hop 2). Multi-tool por
hop + multi-hop = pesquisa, conversa e movimento coexistindo num
turno.
Um handshake de delegação fecha o ciclo: ao delegar um
movimento, o turn.go drena o canal de continuação, emite o
evento de tool-call (o voiceloop executa a coreografia →
sidecar → SDK), emite o evento de resposta concluída, e
bloqueia até o corpo terminar. O próximo hop só roda
depois do aceno real acontecer.
Há um fix aqui que destravou “mexer junto” e custou caro:
coreografias de braço chamavam kWaveHand sem garantir o
modo do robô; kWaveHand falha em modo damping com
rc=400. A correção foi prepender
ChangeMode(kWalking) em toda coreografia de braço. O que
nos leva à regra que mais economizou tempo no projeto inteiro.
rc=0significa comando ACEITO/enfileirado, NÃO executado. Custou mais de 20 horas aprender isso. O SDK aceita um comando e ainda assim o corpo pode nunca se mover (rc=501“low battery stop moving”,rc=400reject genérico, balanceador de marcha suprimindo a faixa de tronco). A máquina de estados deve transicionar emrc==0do lado físico, nunca no enfileiramento. No código, essercviaja como o erro da primitiva:
// RobotRCError carrega o código de retorno do SDK de uma primitiva RECUSADA.
// A lição de campo a duras penas: o "result:ok" do lado Go é apenas o
// ENFILEIRAMENTO — o sucesso físico é o rc==0 do sidecar.
type RobotRCError struct {
Op string // a primitiva recusada (change_mode/move/wave_hand/...)
RC int // código do SDK (501 ServerRefused, 400 rejected, ...)
}Esta é a peça inegociável. A segurança física é uma única fonte da verdade, mora no domínio puro, e nenhum backend consegue contorná-la.
O K1 fica em pé sempre. Três comandos poderiam derrubá-lo: cortar o torque (modo damping), velocidade alta o bastante para tombar, ou replay de uma trajetória que não é um andar projetado. Os três são limitados ou bloqueados — em profundidade:
| Risco | Regra | Aplicado em (3 camadas) |
|---|---|---|
ChangeMode(kDamping) |
só kPrepare/kWalking passam |
domain → adapter Go → sidecar C++ |
Move rápido demais |
clampa \|vx\|,\|vy\|≤0.5 m/s,
\|vyaw\|≤1.2 rad/s |
domain → adapter Go → sidecar C++ |
ReplayTrajectory non-gait |
whitelist de arquivos | domain → adapter Go → sidecar C++ |
const (
MaxLinearSpeed = 0.5 // |vx|,|vy| (m/s)
MaxYawSpeed = 1.2 // |vyaw| (rad/s)
)
func ClampLinear(v float64) float64 { return clampF(v, -MaxLinearSpeed, MaxLinearSpeed) }
func ClampYaw(v float64) float64 { return clampF(v, -MaxYawSpeed, MaxYawSpeed) }
func SafeMode(m RobotMode) bool { return m == ModePrepare || m == ModeWalking }
A mesma regra é chamada três vezes: no orquestrador, no adaptador, e
no sidecar C++ na fronteira do SDK. De modo que até um
orquestrador com bug ou um socket fabricado à mão nunca consegue
comandar uma queda. E não é muleta — um teste
(TestChoreographyVelocitiesWithinSafeEnvelope) garante que
toda coreografia que o sistema possa emitir já nasce dentro do
envelope.
Tem ainda os gates nascidos de tombo real. A bateria:
// Em 13/06/2026 o K1 caiu de cara quando "Dançar" foi apertado a ~2% de
// bateria — o SDK recusou o ChangeMode (rc=501) e os motores perderam torque
// no meio da rotina. Um pack morrendo não segura o corpo durante a dança.
const (
DanceMinSOCDefault = 20.0 // dança
KickMinSOCDefault = 40.0 // chute é mais violento → piso mais alto
GetUpMinSOCDefault = 20.0 // levantar é recuperação essencial → piso mínimo seguro
)
func CanDanceAtSOC(soc float64, seen bool, minSOC float64) bool {
if minSOC <= 0 { minSOC = DanceMinSOCDefault }
if !seen { return true } // carga desconhecida → não dá pra provar inseguro; permite (honesto, logado)
return soc >= minSOC
}Repare no if !seen: o sistema nunca
finge saber a bateria que não mediu. O mesmo princípio vale
para o obstáculo — frame velho demais proíbe o avanço
em vez de adivinhar:
func ClampForObstacle(vx, frontDistM float64, stale bool) float64 {
if vx <= 0 { return vx } // ré/parado: nada a frear
if stale || frontDistM <= ObstacleStopM { return 0 } // cego ou colisão → não avança
if frontDistM >= ObstacleSlowM { return vx } // livre → velocidade cheia
f := (frontDistM - ObstacleStopM) / (ObstacleSlowM - ObstacleStopM)
return vx * f // zona de freada linear
}O modelo canônico é o K1 (22 DOF). Há um primo, o
T1 (23 DOF), que compartilha quase todos os nomes de
junta — mas o K1 não tem a junta da cintura, usa
prefixo A no ombro (ALeft_Shoulder_Pitch) e
tem o Ankle_Cross passivo. Se um nome de junta T1 vazar
para o caminho de controle do K1, você comanda uma topologia errada de
corpo: queda garantida. Um teste de domínio
(TestJointKeysAreK1Members) e um boot check
falham alto se qualquer nome não-K1 aparecer. O robô se
recusa a ligar errado.
Honestidade de débito: não existe política RL de
caminhada nativa do K1. As 12 juntas das pernas têm nomes idênticos ao
T1, então a política do T1 (T1.onnx) é reusada para andar e
girar — com um aviso explícito no boot, nunca silenciosamente.
wscore e o simulador
A arquitetura hexagonal dá um presente: o mesmo
binário que deploya no robô roda no Mac como emulador. Um
switch de ambiente (DUXBOT_ROBOT) escolhe o corpo,
todos satisfazendo a mesma interface RobotController:
null — só loga os comandos.sim — MuJoCo com física + política RL
+ viewer GLFW ao vivo, isolado atrás de //go:build sim (o
cross do robô, sem cgo, nunca puxa o MuJoCo).sidecar — o robô físico, via o sidecar
C++.wscore — um core
Python que detém o DDS + B1LocoClient e
fala WebSocket com o Go.O wscore merece nota. Ele é o caminho
primário de comunicação de movimento e a spec
fonte-da-verdade: hexagonal igual ao Go
(ports/robot.py → booster_sdk.py → ops em
ws_server.py), com uma regra de “dono único do DDS” — só um
B1LocoClient ativo por vez, então quando o wscore é o dono,
o sidecar C++ não roda, e vice-versa. A regra de processo que daí
decorre é inviolável no projeto: capacidade nova de corpo nasce
no core Python (locomoção, gesto, pan/tilt de cabeça, dança); o
sidecar C++ é caminho legado de fallback, não onde coisa nova nasce.
Isso aterra um princípio de desenvolvimento: testar e fazer
funcionar no simulador é obrigatório antes de tocar o hardware.
A ordem é fixa — teste unitário determinístico → prova do “fio” real
contra o ws_server.py (ou o backend Go sim) →
build e testes verdes → só então bump de versão e deploy.
“Quebrou o sim = quebrou o robô.” Hardware nunca é o primeiro teste.
Esta seção é um aviso pago em produção. xAI
(grok-voice-preview-1.0), OpenAI
(gpt-realtime) e outros são “compatíveis com o realtime da
OpenAI” no papel. Na prática, cada um aceita e emite um
subconjunto diferente de campos no session.update
e de tipos de evento.
Os fatos concretos que viraram regra:
Ligar input_audio_transcription no
grok-voice quebra a resposta. Provado em produção (17/06/2026):
o grok passa a só ecoar “USER: …” e para de gerar resposta
conversacional — trata a sessão como “modo transcrição”. E o
detalhe perverso: um probe anterior tinha “passado” porque
media apenas se o campo era aceito (e o evento
…completed emitido), não se o assistente continuava
respondendo. Aceitar o campo ≠ o pipeline seguir
funcionando.
O realtime GA da OpenAI rejeita
session.temperature. “Unknown parameter” — e um
session.update rejeitado derruba junto as
instructions e as tools (incoerência total,
silenciosa). A coerência no caminho padrão vem do system prompt
sozinho.
A regra final ficou: com qualquer realtime audio-native, transcrição de texto desligada, sempre. Ele já faz STT+LLM+TTS no mesmo socket; transcrição em paralelo é redundante e, no grok, fatal. E a regra de processo: nunca portar um campo de um adapter para outro às cegas — mudança específica de provedor entra gated, com default seguro, e só liga onde não é provada após um probe controlado que mede o efeito completo.
Lição transferível: “compatível com a API X” raramente significa “intercambiável com X”. E um teste que mede aceitação sem medir sobrevivência do comportamento é um falso verde perigoso.
Decisão final, medida no robô, não em teoria: o modo oficial de produção é realtime na nuvem (OpenAI/xAI duplex). O caminho 100% offline (ollama no Orin) está congelado — fallback documentado, sem evolução.
Por quê? O Orin NX tem 8 GB unificados (CPU e GPU dividem a
mesma RAM) e 6 núcleos. Enquanto a stack de movimento roda
(motion realtime ~713 MB + ~30 nós ROS2 + 7 containers), não sobra
orçamento para hospedar um LLM. As medidas com um modelo pequeno
(qwen2.5:1.5b):
| Tentativa | Latência/turno | Offload GPU |
|---|---|---|
keep_alive=5m (modelo fixo na CPU) |
~45 s | 0/29 camadas |
keep_alive=0, frio |
27,8 s | 93% CPU / 7% GPU |
keep_alive=0, quente |
100 s | 93% CPU |
num_gpu=99 (forçar offload) |
61 s | não offloadou |
O ollama olha “VRAM livre”, vê pouco (a memória unificada já está tomada por motion+ROS), decide 0 camadas na GPU e cai 100% para CPU — que está faminta de núcleos. Não é problema de config; é a máquina saturada. O que é fluido: realtime na nuvem, sub-segundo. Só o cérebro vai para a nuvem; o motion fica 100% local, com latência de controle intacta.
Mas o caminho offline existe e é engenhosamente
honesto, então vale documentar a engenharia mesmo congelada:
mic → whisper.cpp (STT) → Qwen2.5-3B (decide) → XTTS (TTS) → playback.
Tudo no Orin, sem nuvem nem chave. O tool-calling sem
function-calling nativo é resolvido por Structured
Outputs: as tools são forçadas por um JSON Schema que o ollama
compila numa gramática GBNF (decoding restrito), o que
fisicamente impede o modelo q4 de degenerar (string
não-terminada, vazamento de token de template → loop). A RAM de 8 GB é
gerida em sequência, nunca simultâneo: whisper
transcreve e libera → Qwen carrega, decide, descarrega via
keep_alive:0 → XTTS carrega e toca.
O robô fica num evento real (uma feira de carros antigos, um colégio) e precisa responder com o conhecimento daquele evento — que o LLM não conhece. Isso é um RAG sobre um corpus curado. A métrica que importa é o Context Recall: o trecho certo caiu entre os top-k recuperados? Recall é o teto de tudo — se o pedaço certo nem foi buscado, o LLM alucina ou se desculpa.
Construí um harness (cmd/rageval) que roda um
conjunto dourado (perguntas + trechos esperados) pelo
mesmo caminho do robô e mede o recall. Toda mudança foi
A/B-testada nela antes de entrar em produção. Foi como
descobri que o reranker “de mercado” (um cross-encoder padrão
de tutorial) piorou o recall no corpus em português:
93,5% → 90,3%. Medir matou um mito.
pergunta
├─[QUERY] normaliza → (HyDE: + resposta hipotética)
├─[DENSE] embed(query) <=> embedding (bge-m3, 1024d, cosseno)
├─[SPARSE] to_tsvector @@ tsquery (full-text Postgres, pt)
└─ RRF (funde dense+sparse por rank) → top-k
Cada passo da jornada atacou uma camada diferente — por isso somaram.
Passo A — o sparse engolia termos exatos (93,5% →
96,8%). Diagnóstico nos logs de score: “qual a metodologia da
escola” trazia o chunk errado. O tsquery montava
AND ('metodologia' & 'escola'), mas o
chunk-alvo fala de “metodologia” e “Colégio DUX” e não contém
“escola” → não casava → o sinal léxico zerava. Correção:
tsquery para OR (casar qualquer termo
basta), peso léxico 1.4 no RRF, top-k de 4 para 6. Custo: uma constante
e uma mudança server-side.
Passo B — denominador honesto (a verdade era 97,3%). 96,8% em cima de 31 perguntas é 30/31; não existe “99%” nesse denominador, e 31/31 seria overfit. Expandi o conjunto dourado de 31 para 76 perguntas. A baseline real era 97,3% (73/75) — e os 2 misses tinham o conteúdo existindo no corpus, só mal-rankeado. Não era falta de dado: era ranking.
Passo C — HyDE, perguntar como se já soubesse (97,3% → 100%). A pergunta curta tem vetor ambíguo. O HyDE (Hypothetical Document Embeddings) gera primeiro uma resposta hipotética curta e embeda essa — o vetor de uma resposta plausível cai muito mais perto do chunk certo (que também é uma resposta). A sutileza de implementação:
// semanticChunks:
embText := q
if queryExpandMode() == "hyde" { // DUXBOT_QUERY_EXPAND=hyde
embText = expandHyDE(ctx, hydeClient, question, s.log)
}
vec := embed(embText) // DENSE usa query + hipótese
hybridChunks(eventID, q, vec, poolK) // LEXICAL usa q (termos reais)A hipótese entra só no braço dense; o léxico continua com os termos reais da pergunta (a hipótese inventada poderia não conter a palavra-chave). Melhor dos dois mundos. E fail-safe: qualquer erro (sem gateway, timeout) degrada para a query crua, nunca derruba a busca.
| Passo | Mudança | Camada | Recall |
|---|---|---|---|
| 0 | bge-m3 dense + híbrido | base | 93,5% |
| A | tsquery AND→OR + peso + top-k | sparse | 96,8% |
| B | golden 31→76 | medição | 97,3% (real) |
| C | HyDE (gated) | dense/query | 100% |
O HyDE custa uma chamada LLM por pergunta (~300–800 ms), então fica desligado por padrão e só liga onde precisão importa mais que latência (quiosque, FAQ). O modelo de embedding (bge-m3, via Cloudflare Workers AI, sem OpenAI) não foi trocado — o recall subiu 6,5 pontos mexendo em tudo menos na peça que a maioria troca primeiro.
Um invariante de engenharia que vale ouro: query e ingestão usam o MESMO modelo de embedding. Divergir = cosseno lixo. E o corpus vive em seeds versionados (a fonte da verdade), não em scripts soltos — os scripts ad-hoc de enriquecimento foram declarados obsoletos quando bagunçaram um re-ingest.

O runtime no robô é docker-compose, definitivo.
Quatro serviços, network_mode: host,
ipc: host, env_file montado: jaeger,
robot-bridge, audio-bridge, duxbot-go.
A escolha de host network não é preguiça — é necessidade: 1.
O SDK Booster usa FastDDS (descoberta de peers
por multicast + transporte por memória compartilhada); uma rede
bridge isolaria os dois. 2. O kernel L4T (RT-Tegra) não tem o
módulo iptable_raw → compose up em bridge
falha.
Houve a intenção de migrar para MicroK8s. Foi abandonada, e o racional é um bom estudo de “a ferramenta certa para o nó certo”:
| Dimensão | docker-compose | MicroK8s/K8s |
|---|---|---|
| RAM de overhead | ~0 | 700 MB–1 GB (num Orin com ~571 Mi livres) |
| Roda neste kernel? | ✅ | ❌ snap-confine exige apparmor; o kernel RT não tem |
| Nº de nós que justifica | 1+ | 2+ |
/dev, sockets, FIFO, GPU, host-net |
1 linha | fricção (privileged, hostPath, device-plugin) |
O bloqueio é físico de kernel: o RT-Tegra foi
compilado sem apparmor, e o MicroK8s (snap) não sobe sem ele. Forçar
significaria recompilar o kernel de controle de motor do humanoide —
risco alto, ganho ~zero. Além disso, K8s resolve problemas que um robô
single-node de dono único não tem (escalar entre nós, drenar,
multi-tenant) e atrapalha o que ele precisa (acesso direto a
/dev, sockets, GPU). O bloqueio de apparmor não foi
obstáculo a vencer — foi um sinal de que K8s não
pertence a este nó. (Se um dia houver um segundo robô, o desvio limpo é
k3s — binário systemd, sem snap/apparmor — não MicroK8s.)
Importante não confundir: “robôs precisam de imagens no
registry” ≠ “robôs precisam de K8s no robô”. A entrega de uma
frota é uma camada central (CI builda arm64 → registry → cada
robô puxa a mesma tag imutável), enquanto cada robô segue rodando
docker-compose local. E todo provisionamento — apt,
Docker, ollama, sidecars, units, config.env — passa por
Ansible. Edição ad-hoc por SSH é proibida; SSH manual
só para diagnóstico read-only.
Por fim, o versionamento como SSOT: a versão de tudo
vem de um arquivo só (robot/VERSION), carimbada em três
lugares que devem concordar — o binário Go (via -ldflags),
o label OCI da imagem, e a navegação do admin. Um comando
(task version) cruza os três e responde “o que está no
ar?”. (Histórico: o robô rodou uma imagem :latest velha por
dias sem ninguém perceber — :latest esconde código antigo.
O label OCI mata isso.)

O DuxBot é mais que voz. As capacidades opcionais, todas atrás de ports e nil-guarded (ausentes, degradam graciosamente):
Percepção / para-choque virtual
(PerceptionInput): um nó no Orin publica a distância do
obstáculo frontal mais próximo (~5–10 Hz); o domínio a transforma em
clamp de velocidade (ClampForObstacle). Monocular hoje,
porque a câmera estéreo tem o trigger de sincronismo LPWM
fisicamente quebrado (defeito de hardware, RMA
pendente; a câmera roda em free-run como workaround, com
profundidade estéreo degradada mas detecção 2D intacta).
Visão / what_do_you_see
(VisionInput): um nó YOLO COCO no Orin publica a lista de
objetos/pessoas detectados; vira overlay de caixas no controle
remoto e uma tool que o robô usa para contar o que vê (“vejo duas
pessoas e uma cadeira”).
Identidade (IdentityInput): rosto
(InsightFace) + voz (SpeechBrain) fundidos por uma política de domínio
(FuseIdentity — pesos, concordância de nome, threshold). As
tools who_is_this / enroll_person.
Telemetria (body_status): o robô
responde “qual sua bateria?”, “tá quente?” com dados
reais do corpo. É push via DDS, não RPC — o
sidecar assina os tópicos uma vez no boot e cacheia o último frame; cada
bloco tem flag “seen”, então antes do primeiro publish o robô é honesto
(“ainda tô ligando os sensores”) em vez de inventar número.
Wake word “hey duxbot”
(WakeDetector): openWakeWord (ONNX ~200 KB) num sidecar por
socket. Débito honesto: o modelo custom treinado só com áudio sintético
(edge-tts) deu overfit — pontuava 0,85 em treino e 0,0005 no
mic NAEC real. Interino: o builtin hey_jarvis, bem-treinado
de fábrica. Retreino pendente com positivos reais de campo.
Todas seguem a mesma forma de três camadas e o mesmo fail-safe: sinal velho ou nó morto degrada para o estado seguro, nunca para a adivinhação.
A confiança no sistema vem de quatro camadas complementares de prova, do determinístico ao físico:
Cola determinística
(domain/routing_e2e_test.go, ~100+ casos, offline,
sempre-verde): pina o roteamento do domínio — quando a busca ao vivo
dispara/segura, quando o guard bloqueia palavrão/código, quando uma tool
de movimento vira coreografia. É o gate de CI.
Bench ao vivo (cmd/duxbench,
219 falas de criança): dirige o binário real um disparo
por caso e mede o que só o cérebro real responde — precisão de
roteamento (216–217/219 ≈ 98,6–99,1%, estável em 4
rodadas), concisão (mediana 10 palavras) e
idioma (zero vazamento de script não-latino). Sai com
erro se a precisão cai abaixo do mínimo — pode gatear
release.
Bench multi-turno (cmd/duxevolve,
51 checks): prova a evolução de contexto que o one-shot
não vê — carry de pronome (“quem é o Elon Musk” → “quantos filhos
ele teve”), follow-up aditivo (“a capital da França” → “e
a da Alemanha” → Berlim), estado do braço mantido, memória de nome,
reset por silêncio. 50/51 = 98%.
União-corpo
(choreography_sim_e2e_test.go, -tags sim,
MuJoCo, sem robô): fecha a solda entre roteamento e física. Dirige a
coreografia pelo orquestrador real contra o corpo
MuJoCo K1 e afirma que cada intenção move o corpo e o robô nunca
cai (TrunkZ ≥ 0.4). 15/15 — incluindo um teste que
roda os 10 movimentos em sequência sem uma queda.
E uma regra de linguagem de código que parece pedante mas mantém a
base coerente: comentários em pt-BR (quem lê o fonte é brasileiro),
mensagens de diagnóstico (fmt.Sprintf de log/erro/OTel) em
inglês, e — intocável — tudo que o robô fala ou que o
LLM lê (system prompt, descrições de tool, resultados
de busca) em pt-BR.

São treze seções e uma dúzia de subsistemas, mas o princípio é um só, repetido em cada decisão: disciplina sobre esperteza, e honestidade sobre o que não se sabe.
rc=0 não é sucesso físico — o
aprendizado de 20 horas que reescreveu como a máquina de estados confia
no mundo.O corpo, o SDK da Booster já tinha resolvido. A camada fina, pura, testável e honesta entre o “oi” de uma criança e um humanoide de 22 articulações em movimento — essa foi a engenharia.
Stack: Go (agente hexagonal, cross-compile arm64 estático,
GOWORK=off CGO_ENABLED=0) · C++ (sidecars
robot_bridge/audio_bridge sobre o SDK Booster,
unix sockets) · Python (wscore — core de movimento
hexagonal + simulador MuJoCo de referência) · Postgres/pgvector (RAG
híbrido dense+sparse+RRF) · Cloudflare Workers AI (embeddings bge-m3
1024d) · OpenAI/xAI realtime (cérebro duplex audio-native) · Jaeger/OTel
(tracing correlacionado nuvem→robô) · Ansible (provisionamento 100%) ·
docker-compose (runtime no Jetson Orin NX).