This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Copiloto de reuniones local: captura el audio del sistema, lo transcribe en vivo en la GPU y usa un LLM (Claude vía suscripción o un modelo local con Ollama) para sugerir respuestas y dar contexto, todo en una ventanita flotante. Pensado para Fedora + Wayland + NVIDIA. Todo el procesamiento es local; con Claude solo el texto de la transcripción sale del equipo; con Ollama nada sale.
Hay tres motores ASR y cada uno vive en su venv porque sus dependencias (torch/NeMo) chocan:
.venv— motor whisper (faster-whisper/RealtimeSTT).pip install -e ".[dev]"..venv-parakeet— motor parakeet (NVIDIA NeMo 2.7.x, liviano, respaldo). Se instala conuv pip(es un venv de uv, no tienepippropio; usauv pip install --python .venv-parakeet ...). El paqueteasistentese instala ahí con--no-depspara no arrastrar las deps de whisper..venv-nemotron— motor nemotron (NVIDIA Nemotron 3.5 ASR streaming 0.6B, cache-aware, recomendado). Requiere NeMo 26.06 / git main (uv pip install --python .venv-nemotron "nemo_toolkit[asr] @ git+https://github.com/NVIDIA/NeMo.git@<commit>")asistente --no-deps. Streaming nativo de baja latencia y mejor WER en español. Antes de confiar en él, corre la puerta de verificaciónscripts/derisk_nemotron.py(carga, latencia, glosario).
El motor se elige con engine = "nemotron"|"parakeet"|"whisper" en config.toml. Corre run.py con el venv que
corresponda al engine o saldrá un error claro. Los tres venvs están gitignorados.
# Correr (Nemotron, recomendado; la 1ª vez descarga el modelo ~2.4GB)
.venv-nemotron/bin/python run.py --native # ventana nativa PySide6 (mejor en Wayland)
.venv-nemotron/bin/python run.py --native --engine parakeet # --engine pisa el config
.venv/bin/python run.py --native --engine whisper # whisper (corre en CPU con [whisper] device="cpu")
.venv-nemotron/bin/python run.py --no-window # solo servidor; abre la URL (útil en 2da pantalla)
# Tests (corren en .venv; son unitarios de lógica pura, sin GPU/Claude)
.venv/bin/python -m pytest -q
# De-risk / diagnóstico (scripts sueltos en scripts/)
.venv/bin/python scripts/derisk_audio.py --list # node-id del monitor del sink
.venv-nemotron/bin/python scripts/derisk_nemotron.py --wav muestra_es.wav # puerta C0 de Nemotron
.venv/bin/python scripts/derisk_ollama.py --model gemma4:e2b # latencia de un LLM local
# Atajos globales (GNOME; con el asistente corriendo)
bash scripts/set_hotkey.sh "<Super>a" # mostrar/ocultar (/toggle)
bash scripts/set_hotkey.sh "<Super>g" ghost # modo fantasma / click-through (/ghost)config.toml (copia de config.example.toml, gitignorado) controla todo: engine
(nemotron|parakeet|whisper), [brain] backend (claude|ollama) + [ollama] (model/host),
[nemotron] (att_context_size, target_lang) y [asr] (glossary), [context] (briefing,
briefing_file, window, summary_every), [audio] target (node-id del monitor, p.ej. "122"),
[whisper] (modelo, idioma, device cuda/cpu), [parakeet] (silence_rms/autocalibración),
[user] (names + role), [copilot] (enabled, interval_s), [server] token.
Pipeline de 5 piezas, cada una con interfaz simple. run.py (en la raíz) es el ensamblador que las cablea:
PipeWire monitor → Transcriptor → Orquestador (FastAPI/WS) → UI flotante
(pw-record) (GPU, hilos) │ ▲ (PySide6 nativa / web)
▼ │
Cerebro (Claude -p caliente / Ollama local)
capture/pipewire.py—PipeWireCapture.stream()lanzapw-recordy entrega chunks PCM s16 mono 16k.transcribe/— tres implementaciones con el mismo interfaz (start(pcm_chunks, sr),stop(), callbackson_partial/on_final):nemotron_stt.py(NeMo cache-aware streaming, recomendado),parakeet_stt.py(NeMo + VAD RMS, con autocalibración del umbral) ywhisper_stt.py(RealtimeSTT, único que corre en CPU). Helpers puros:clean.py(filtra alucinaciones + quita etiquetas<es-US>),correct.py(corrige el glosario por similitud),endpoint.py(segmenta el stream acumulado en finales).context.py—SessionContext: contexto en 3 capas (briefing durable + resumen acumulativo + ventana rodante) concompose(max_chars). Fuente única que alimentan copiloto, sugerencia y "preguntar".server/app.py—create_app(cfg, brain): sirve la UI, expone/ws(token),/toggle(mostrar/ocultar),/ghost(modo fantasma) y/health.app.state.broadcast(ev)manda eventos;app.state.ctxes elSessionContextcompartido. Atiendeaskybriefing.set.brain/— cerebro intercambiable por[brain] backend:claude_client.py(WarmClaude, procesoclaude -ppersistente, stream-json) uollama_client.py(OllamaBrain, LLM local vía HTTP). Mismo interfazask/prewarm/stop.copilot.py—build_copilot_prompt+parse_copilot(RESUMEN/IDEAS/BORRADOR/ALERTA por prefijos).detect.py— heurísticais_question_for_me(pregunta + dirigida a ti por nombre/segunda persona).events.py— contrato pydantic del WebSocket (la fuente de verdad de los tipos; las UIs lo consumen).ui/native.py— widget PySide6/QtWidgets (recomendado), con temas, panel de respuestas (ui/history.py), modo fantasma.ui/launcher.py(pywebview) yui/web/(HTML/JS) son alternativas.
Flujo de la IA (todo en run.py:main): la transcripción se difunde como transcript.partial/final y entra
al SessionContext; en cada texto se corre is_question_for_me (sobre parciales y finales) → si hay
pregunta dirigida a ti, se pide sugerencia al cerebro (con cooldown + guard "en vuelo"). En paralelo,
copilot_loop cada interval_s manda ctx.compose() al cerebro, difunde un Insight (panel 🧠), guarda un
borrador para responder al instante, y cada N finales actualiza el resumen acumulativo. Las llamadas a
brain.ask se serializan con un lock.
claude -p"descafeinado": en frío tarda 5-9s. Se usa caliente (proceso persistente) y stripped:--setting-sources "" --strict-mcp-config --allowed-tools "" --system-prompt ... --exclude-dynamic-system-prompt-sections. Con eso el TTFT baja a ~2-3s tras un prewarm (1ª llamada dummy). El parser DEBE ignorarthinking_delta(Haiku piensa antes del texto) y recoger solotext_delta; el eventoresultcierra.- Parakeet OOM: cargar el modelo
from_pretrained(map_location="cpu").half().to("cuda")— si se carga directo en GPU el pico es ~5GB (fp32) y revienta la VRAM; en CPU→fp16→GPU el pico es ~1.3GB. - Nemotron — prompt de idioma: es
EncDecRNNTBPEModelWithPrompt. Sinmodel.set_inference_prompt("es"|"auto")decodifica vacío (concatena un one-hot de idioma al encoder en cada chunk). El modo"auto"emite etiquetas<es-US>/<en-US>→ se quitan conclean.strip_lang_tags. - Nemotron — fp32 + sin CUDA graphs: en fp16 el decoder RNN-T con CUDA graphs mezcla dtypes ("mat1 and
mat2 must have the same dtype"). Se corre en fp32 (~2.7GB) y se desactiva
greedy.use_cuda_graph_decoder(los chunks de streaming cambian de tamaño y los graphs asumen forma fija). - Nemotron — no comerse palabras: usar
CacheAwareStreamingAudioBuffer(preprocesa el audio UNA vez) en vez de re-preprocesar ventanas, y consumir SOLO chunks completos (available >= chunk_size; el__iter__del buffer no valida tamaño y avanzaríabuffer_idxde más, saltando audio). - Nemotron — sesiones largas: el RNN-T acumula la hipótesis sin límite; se fuerza un final por pausa o por
largo (
max_partial_chars) y se reinicia la hipótesis del decoder cuando no queda pendiente (el cache acústico del encoder se mantiene), o la sesión se ralentiza hasta congelarse. - Idioma:
[whisper] language = ""⇒ autodetección bilingüe (es/en)."es"lo fuerza (más rápido al arrancar). - Sugerencia proactiva en parciales: con audio continuo de reunión casi nunca hay finales (necesitan silencio), así que la detección corre también sobre los parciales, no solo finales.
- QtWebEngine segfaultea en este Wayland → la ventana pywebview (sin flag) es inestable; usar
--native(QtWidgets, estable) o--no-window(navegador). El widget nativo fuerzaQT_QPA_PLATFORM=xcb(XWayland) para que frameless/always-on-top/arrastre funcionen. - Atajos globales en Wayland: la app no puede capturarlos; se hace con un atajo de GNOME →
curl /toggleo/ghost→ broadcast de un evento → la ventana alterna visibilidad o modo fantasma (el WS sigue vivo aunque esté oculta o atravesable). - Stealth real en Wayland = imposible desde la app: el compositor ya pintó la ventana en el frame que se
comparte y ningún protocolo (
xdg-desktop-portal,wlr-screencopy) ofrece exclusión por ventana. La discreción es: compartir una ventana (no la pantalla), 2da pantalla, o modo fantasma (click-through víaQt.WindowTransparentForInput). El stealth de verdad será del port a Windows (WDA_EXCLUDEFROMCAPTURE). - Cerrar mata el proceso: el
closeEventllamaapp.quit()yrun.pyhaceos._exit(0)(con cleanup con timeout), porque los hilos de NeMo/RealtimeSTT se cuelgan al apagar. - Puerto ocupado:
run.pyrevisa el puerto y avisa claro (en vez del traceback de uvicorn) si ya hay una instancia. Para matar una pegada: por el PID del puerto (ss -ltnp | grep :8765), no conpkill -f run.py(el wrapper del shell contiene "run.py" y se auto-mata). - Alucinaciones de Whisper/ASR ("You", "¡Suscríbete!", "Thanks for watching" sobre silencio/música) se
filtran en
transcribe/clean.py.
TDD/tests unitarios para lógica pura (events, config, detect, clean, copilot, context, history, ollama,
corrección+endpointing del ASR, claude framing, server WS). Las piezas atadas a hardware/GPU/LLM se validan
con scripts de de-risk "ejecuta y observa" (en scripts/), no con tests automatizados.