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
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
-
- 4 GB de VRAM compartidos entre el modelo de lenguaje y RVC. Es la restricción que define todo el proyecto.
- Defectos de bind en
rem.vrm.Iapunta avrc.v_ffyUno tiene bind. Las curvas delookAtson identidad lineal con tope de 10°: el ojo gira 1/9 de lo que se le pide. - WebKitGTK 2.52. El proceso de red se cae al cargar el VRM, y el autoplay necesita
WebKitWebsitePolicies, no elset_media_playback_requires_user_gestureque 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.pyni 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_aasinoBodybaked_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
SentenceSplitterno 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
VRMLookAtQuaternionProxyque pisaba mi control de la mirada. - Descarté
Relax.vrmacomo pose base - Daba T-pose. El frame 0 se ve correcto y engaña: hay que medir el ángulo del
upperArma lo largo de todo el clip.
Métricas antes y después
| 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.
| 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.
| 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ó.