Saltar al contenido
Esteban

Proyecto Rem

Compañero de escritorio con avatar 3D VRM que conversa por voz, con el modelo de lenguaje y la conversión de voz corriendo en local. El reto no fue ninguna pieza por separado, sino hacerlas convivir en 4 GB de VRAM.

Rol
Diseño y desarrollo completo
Duración
2026, en curso
Stack
Python, GTK3 + WebKit2GTK, Three.js + three-vrm, Ollama, edge-tts + RVC
Cliente
Proyecto propio

Código en GitHub

Contexto y restricciones

Rem responde con un modelo de lenguaje que corre en mi equipo, habla con una voz sintetizada por edge-tts y convertida localmente con RVC, y mueve un avatar VRM que sincroniza labios, mirada y cuerpo con lo que dice.

Cada pieza funcionaba por separado. El proyecto de verdad empezó cuando tuvieron que compartir la misma GPU. Rem nació en Windows y hoy corre en Arch Linux con Hyprland.

Presupuesto
Cero costo de operación, y no por gusto: la API de Anthropic devolvió 400 por saldo insuficiente, porque la suscripción Pro no incluye créditos de API. Eso convirtió «local» de preferencia en requisito. Hardware: RTX 3050 con 4 GB de VRAM y 24 GB de RAM.
Tiempo
Desde el port de Windows a Linux hasta el ciclo completo funcionando, en paralelo con el trabajo. Hoy está en etapa final, con mejoras pendientes.
Usuario
Yo, en Arch Linux con Hyprland. Proyecto propio, sin cliente.
Limitaciones técnicas
  1. 4 GB de VRAM compartidos entre el modelo de lenguaje y RVC. Es la restricción que define todo el proyecto.
  2. Defectos de bind en rem.vrm. I apunta a vrc.v_ff y U no tiene bind. Las curvas de lookAt son identidad lineal con tope de 10°: el ojo gira 1/9 de lo que se le pide.
  3. WebKitGTK 2.52. El proceso de red se cae al cargar el VRM, y el autoplay necesita WebKitWebsitePolicies, no el set_media_playback_requires_user_gesture que aparece en casi toda la documentación.

Decisiones

Port a Linux

Elegí Portar Rem a Arch Linux y, en el camino, meter la conversión de voz dentro del proceso con infer-rvc-python.

Me pasé a Arch con Hyprland por rendimiento del equipo y comodidad para programar, y Rem tenía que venirse conmigo.

En Windows la conversión de voz corría en una aplicación aparte, que cargaba los archivos del modelo de Rem, y VB-CABLE hacía de tubería: tomaba la salida de un programa y la entregaba como entrada de otro, porque en Windows no hay forma directa de conectarlos.

En Linux no busqué un equivalente de VB-CABLE: eliminé esa capa. Con infer-rvc-python, RVC corre dentro del mismo proceso y el audio nunca sale del programa. Se fueron el cable virtual, la aplicación externa y la configuración de dispositivos de entrada y salida.

Runtime de escritorio

Elegí GTK3 + WebKit2GTK, con el frontend en HTML y Three.js dentro de un webview.

Durante meses mantuve dos intérpretes de Python porque asumí que GTK exigía el del sistema. Nunca lo había probado. pip install pygobject pycairo compila contra las librerías del sistema y funciona dentro del venv.

Descarté Tkinter
Venía del origen en Windows, y el intérprete 3.10.14 del proyecto estaba compilado sin _tkinter: Rem.py ni siquiera arrancaba.
Descarté Overlay flotante con gtk-layer-shell
Funcionó, pero cuando llegó la ventana de chat quedó redundante y lo eliminé.

Render del avatar

Elegí Three.js con @pixiv/three-vrm, escribiendo los morph targets por índice directo.

El modelo tiene un único mesh, Body.baked, con 72 primitives que comparten el mismo orden de 59 targetNames. Un índice vale igual en las 72, así que el lipsync deja de buscar nombres y escribe en una posición conocida.

Descarté Fallback de 5 visemes vía expressionManager
Era la vía activa y 0 de 14 visemes resolvían: los morphs del modelo cargado no se llaman vrc.v_aa sino Bodybaked_NN.

Modelo de lenguaje

Elegí Qwen3.5-4B en Q4_K_M servido por Ollama, detrás de una interfaz LLMProvider con implementaciones intercambiables (claude.py, local.py, groq.py, echo.py) y una factory que lee config.toml.

La abstracción tuvo su prueba real al cambiar de proveedor: en Rem.py solo cambió el cuerpo de una función y se agregó un puente de síncrono a asíncrono.

Descarté Groq
Dependencia de un servicio externo y de una key.
Descarté API de Anthropic
Costo por turno, y el saldo ya no alcanzaba.

Voz

Elegí edge-tts con es-VE-PaolaNeural a --rate=-8%, convertido por RVC en GPU con pitch 4 e index_influence 0.75.

Con index_influence más bajo (0.5, 0.3 y 0) la conversión se acercaba a la voz original de Paola y se perdía el timbre de Rem. Preferí quedarme en 0.75 y asumir el artefacto de la R.

Descarté RVC en CPU cuando apretó el OOM
13,6 s por frase corta contra 3–5 s en GPU, unas 4,5 veces tiempo real. La cola del SentenceSplitter no puede seguir ese ritmo.

Animación del cuerpo

Elegí Clips .vrma reales compuestos sobre una capa procedural (respiración, micro-saccades, peso), con el AnimationClip construido a mano solo con las pistas humanoid.

Para confirmar que no perdía nada, comparé contra la función oficial: las pistas que genero son idénticas byte a byte en 50 de 50, solo que sin las de ojos, mandíbula y expresión.

Descarté createVRMAnimationClip() oficial
Genera también pistas de expresión y mirada, y crea por su cuenta un VRMLookAtQuaternionProxy que pisaba mi control de la mirada.
Descarté Relax.vrma como pose base
Daba T-pose. El frame 0 se ve correcto y engaña: hay que medir el ángulo del upperArm a lo largo de todo el clip.

Métricas antes y después

Conversión de voz con RVC, por frase

Antes 11–16 s
Después 0,88 s

Recarga del modelo de lenguaje entre turnos

Antes 4,0–4,5 s
Después 0,2–0,3 s
Mediciones de Proyecto Rem
Métrica Antes Después Cambio Cómo se midió
Conversión de voz con RVC, por frase 11–16 s 0,88 s 12–18× más rápido Log de tiempos del pipeline; primera conversión real tras el calentamiento
Recarga del modelo de lenguaje entre turnos 4,0–4,5 s 0,2–0,3 s 13–22× más rápido Tiempo de carga que reporta Ollama en cada turno caliente; el primer turno sigue en frío (~4,2 s)

Por qué cambió cada número

Conversión de voz. BaseLoader.__call__() guardaba el tag del modelo cargado en cache_params, una variable local que se reinicia en cada llamada, así que el .pth se volvía a leer en cada frase. Lo cambié por BaseLoader.generate_from_cache(), que cachea en atributos de instancia.

El cambio trajo algo que no buscaba: generate_from_cache() llama a infer() directo, sin pasar por un hilo interno, y ahora un fallo lanza una excepción en lugar de devolver una lista vacía en silencio. Como BaseLoader no es thread-safe, un Lock serializa las conversiones.

Recarga entre turnos. keep_alive estaba en 0: Ollama descargaba el modelo al terminar cada respuesta y lo volvía a cargar en la siguiente. Con el modelo residente, solo el primer turno paga la carga en frío.

Contención de VRAM entre el modelo y RVC

No es una optimización, es una medición de aislamiento: la misma generación en tres escenarios.

Velocidad de generación de Qwen3.5-4B Q4_K_M según qué más ocupa la VRAM. La comparación es entre escenarios a num_gpu fijo, no contra el máximo de la GPU: la referencia de 24,74 tok/s no registró con cuántas capas corrió.
Escenario Generación
Solo el modelo, nada más corriendo (ollama run --verbose) 24,74 tok/s
Con RVC residente en VRAM, en reposo 9,0 tok/s
Con RVC convirtiendo en paralelo ~4 tok/s

La sola presencia de RVC en la VRAM, sin convertir nada, baja la generación un 64 %.

Dos intentos que no funcionaron: bajar num_ctx a 4096 no liberó lo suficiente, porque la atención híbrida de Qwen3.5 ya usa una caché KV pequeña; y con PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True llegaron a faltar 12 MiB.

Calibración de capas en GPU

Barrí num_gpu, cuántas capas del modelo van a la GPU, con un criterio: la generación más rápida con la que RVC no falla.

Barrido de num_gpu con RVC residente.
num_gpu VRAM Generación RVC
32 (todas) ~2994 MiB ~40,7 tok/s OOM siempre
28 ~2714 MiB ~25,8 tok/s Fiable

Empecé en 28. Bajé a 24 cuando la ventana de chat reemplazó al overlay: su canvas WebGL es mucho más grande que los 520×860 del overlay, y con 28 capas quedaban unos 120 MiB de margen.

Equipo y método

Intel Core i5-12500H, RTX 3050 con 4 GB de VRAM, 24 GB de RAM, Arch Linux. VRAM medida con nvidia-smi; tokens por segundo con ollama run --verbose y con los tiempos del propio pipeline.

Qué haría distinto hoy

Mediría el presupuesto de VRAM antes de elegir el modelo. Los 4 GB definían el proyecto y los descubrí a golpes, después de haber elegido modelo, cuantización y stack de voz. Las semanas siguientes fueron negociar con ese límite. Una medición de veinte minutos al principio habría cambiado el orden de las decisiones.

Auditaría la seguridad desde el principio. El repositorio es público desde el primer commit y el modelo puede ejecutar acciones en el equipo. La auditoría de superficie de ataque sobre esas acciones llegó al final: de ahí salieron una validación de rutas más estricta, una whitelist de binarios y el borrado a la papelera en lugar del borrado definitivo. Todo eso tenía que existir antes de publicar.

Probaría las suposiciones de entorno antes de construir encima. Mantuve dos intérpretes de Python durante meses por algo que nunca verifiqué. Una línea de pip lo desmintió.