NOVA
Una criatura de escritorio con ojos, sonidos propios, memoria y carácter. No es otra app, ni otro chatbot, ni un asistente de voz: es un objeto con cara que reacciona a lo que pasa a su alrededor y se despierta cuando dices su nombre.
- Precio objetivo
- 69–99 €
- Partes móviles
- 0
- Activación
- Por voz, en el dispositivo
- Frases que dice
- 3 o 4 en toda su vida
- Gestos en la placa
- 32, cada uno con su sonido
- La cara en la placa
- 61 FPS, desde 21
- Fase 1 cerrada
- 16 de septiembre
- Stack
- ESP32-S3, ESP-IDF, LVGL, I2S, Java 21, Spring Boot 4, Postgres, WebSocket
NOVA es una criatura pequeña que vive en la mesa. Tiene ojos, expresiones, un repertorio de sonidos propios sin palabras, nombre, memoria de lo que ha pasado entre vosotros y se activa cuando la llamas por su nombre. No tiene ruedas ni motores, y desde el 18 de septiembre tampoco conversa: no es un robot ni un asistente de voz, es un objeto con cara que reacciona a lo que pasa a su alrededor.
Es también mi primer proyecto de hardware, y ese era el objetivo principal antes que cualquier otro.

Por qué existe
Tres objetivos, por orden:
- Aprender embedded. Primer proyecto de hardware.
- Construir algo no clonable. Un personaje, no un commodity. El hardware no diferencia, porque cualquiera puede pedir las mismas piezas; el carácter y el acabado son el único foso.
- Intentar viralizar. Vender diez unidades con lista de espera y crecer desde ahí.
El coste ya está gastado y no depende de que un vídeo funcione. Lo comprado de verdad es aprender embedded haciendo algo que apetece.
El vídeo del que cuelga todo
El guion original tenía cuatro líneas:
Tú: «I built a ChatGPT pet.»
La criatura: «You haven’t talked to me in 3 days.»
Tú: «What?»
La criatura: «I know.»
Lo importante de ese guion es que la línea de los tres días no es una respuesta del modelo de lenguaje. Es un temporizador. Toda la arquitectura sale de ahí.
Desde que NOVA no habla (D16), el guion usa esa decisión a favor: es un bicho que no habla, hasta que habla.
Tú: «I built a desk pet. It doesn’t talk.»
(NOVA gorgotea, parpadea, te mira.)
Tú: «See? It just makes sounds.»
La criatura: «You haven’t talked to me in three days.»
Tú: «What?»
La criatura: «I know.»
Esas dos líneas son dos archivos de audio y un temporizador. Lo que se cae es el titular: «I built a ChatGPT pet» ya no describe el producto y no hay sustituto cerrado. El candidato, desde que existen las actividades (D18), es la mascota que trabaja contigo.
Dónde se coloca
El mercado de compañeros con IA existe y es más grande de lo que parece, pero vive en el rango premium:
| Producto | Precio | Tracción |
|---|---|---|
| Ropet | 299 $ | ~400.000 $ en Kickstarter, 1.134 patrocinadores |
| Casio Moflin | ~380 $ (59.400 ¥) | Más de 7.000 unidades, agotado |
| Loona, EMO, Eilik | 200–500 $ | — |
NOVA se posiciona en 69–99 €, un hueco que casi nadie ocupa. Y hay un dato de esa tabla que pesó en la decisión más grande del proyecto: Moflin no dice una sola palabra.
Decisiones, con su porqué
Cada una está escrita para no volver a discutirla dentro de tres semanas. Varias han sido revisadas por decisiones posteriores; se dejan con su razonamiento original y la nota de qué cambió.
D1. NOVA sobre las otras 33 ideas
La hoja de cálculo con las 34 candidatas tenía un bug de escala: la columna de margen iba de 0 a 100 y todo lo demás de 0 a 10. Resultado: el margen pesaba un 47 % real en la puntuación total en vez del 10 % declarado, y el ranking estaba ordenado por margen bruto. La segunda columna tenía el problema inverso, con el encaje técnico pesando un 78 %.
Corregida la escala, NOVA sube del puesto 13 al 8 en mercado y al 2 con criterios de primer proyecto. Es lo único de la lista que puntúa 10/10/10 en diferenciación, viralidad y encaje técnico a la vez; la hoja la penalizaba por margen y capital, que son irrelevantes cuando vendes diez unidades para validar.
Una señal que salió de paso: varias ideas del top 10 tenían como referencia una búsqueda de categoría de Etsy. Cuando tu benchmark es una página de mercado, el producto ya es un commodity.
D2. Placa integrada, no componentes sueltos
Comprar una placa que ya lo trae todo y escribir solo firmware. A favor: funcionando el primer fin de semana en vez de tres semanas peleando con I2S en protoboard. Coste: menos GPIO libres y menos control del layout. Cuándo cambiar: a partir de la unidad diez, con PCB propio.
D3. Sin batería en la v0
El ESP32-S3 con WiFi activo consume 150–250 mA; 1.000 mAh dan cuatro o cinco horas, es decir, un objeto que se muere justo cuando lo necesitas. Es un objeto de escritorio: vive enchufado. Además evita la directiva de baterías y el riesgo de las LiPo. La placa comprada trae gestión de batería y conector, así que la opción queda abierta.
D5. La v0 no tiene voz
La decisión que más riesgo eliminaba. La v0 tenía ojos, expresiones, tres botones, la máquina de estados con decaimiento y los mensajes proactivos como texto en pantalla. Sin reconocimiento de voz, sin modelo de lenguaje, sin síntesis. Cero coste de inferencia mientras se valida, cero problema de latencia de audio y, sobre todo, que la parte difícil de verdad, el carácter y el timing, se resolviera antes de añadir complejidad.
Revocada en la práctica por D14: el razonamiento sigue siendo correcto, pero esa v0 privada nunca se construyó.
D6. Cada usuario pone su propia clave de API
El coste que mata no es la lista de materiales, es la inferencia: diez minutos de conversación al día son 2–6 € al mes por unidad viva. Con una venta única de 89 € tienes unos dieciocho meses de pista y luego pierdes dinero con cada cliente activo. Esto ha matado a la mitad de los gadgets de IA de los últimos años. Clave propia del usuario significa coste recurrente cero y cero responsabilidad sobre datos.
Revisada por D16: el dispositivo ya no hace inferencia, así que no hay clave que pedir para que NOVA funcione. El coste recurrente no se trasladó al usuario: desapareció. La clave queda, como mucho, como opción de la app para quien quiera funciones de texto con un modelo.
D7. Máquina de estados, no modelo de lenguaje
La decisión de arquitectura más importante del proyecto. «Un LLM con memoria» produce un chatbot amable. «Un Tamagotchi que sabe hablar» produce una criatura. El estado vive fuera del modelo; el modelo solo lo verbaliza, y a veces ni eso. D16 es la conclusión natural de esta decisión: quita el verbalizador y deja el corazón intacto.
D8. El tiempo se inyecta
La máquina de estados no depende del reloj de pared. Recibe la fuente de tiempo, y en los tests la controlo yo: seis meses de abandono se simulan en un test unitario, sin placa. El único caso que necesita tiempo real es el ensayo final antes de grabar, para verificar que el dispositivo aguanta tres días encendido con reconexión WiFi y RTC sin colgarse. Desde D18 la misma regla vale para las actividades que corren en la placa.
D9. Sin cámara
Tres motivos, en orden de peso:
- Físico. Una cámara DVP paralela necesita 10–12 pines. Hay tres GPIO libres y ningún conector de cámara. No es difícil: no cabe.
- De producto. En cuanto NOVA tiene un ojo real deja de ser un objeto tierno y pasa a ser algo que la gente tapa con una pegatina.
- De coste. Tokens de visión, y GDPR.
El sustituto es presencia por radar de ondas milimétricas y dirección de sonido por el array de micrófonos (ver percepción).
D10. La personalidad se prototipa en el navegador
Parpadeo, sacadas, respiración, gestos, muelles y personalidades de timing se desarrollan en un canvas HTML de 466×466, el mismo tamaño y forma que la pantalla real. El ciclo guardar-refrescar contra compilar-flashear-40 s es diez veces más rápido. Lo que se porta después se parte en dos: los datos (presets, constantes, secuencias de gestos) se exportan desde el banco; el dibujo se reescribe de canvas a LVGL con la misma lógica.
El banco no es parte del producto: es el cuaderno de bocetos. Desde el 19 de septiembre es también el banco de gestos y de sonidos, y desde el 20 exporta él mismo la cabecera C que compila el firmware, para que las dos copias no vuelvan a divergir.
D11. Java y Spring Boot para el backend
Sobre Python y Express. Donde soy más fuerte, y en un proyecto sin sueldo la
fricción es lo que mata. Además, java.time.Clock es literalmente la fuente
de tiempo inyectable que pide D8: la clase existe para esto. El dominio queda
sin dependencias, así que sobrevive a cambios de versión del framework.
Spring Boot 4, no 3: Jakarta EE 11, Java 17 o superior y todo lo deprecado en 3.x eliminado. Consecuencia práctica: los tutoriales de 2024 no compilan.
D12. Se activa por voz, no con botones
El producto se despierta diciendo su nombre. Ningún botón en la experiencia que ve el mundo.
Un wake word como «Alexa» viene ya entrenado; «NOVA» no existe en ningún catálogo. El camino oficial de Espressif (WakeNet) exige, para una palabra propia, un corpus grabado por más de 500 personas, incluidos al menos 100 niños, en sala insonorizada: escala de empresa. El camino elegido es microWakeWord, del ecosistema Home Assistant: corre enteramente en el dispositivo, nada se envía a ningún sitio hasta que se detecta la palabra, y se entrena con audio sintético generado por TTS. La placa está pensada para esto: su ficha describe el array de dos micrófonos explícitamente para voice wake-up.
Sigue vigente tras D16, con un cambio en lo que ocurre después de la palabra: ya no abre una conversación, activa un comando de una lista cerrada. Eso hace el reconocimiento más fácil, no más difícil: en vez de transcribir cualquier frase, hay que distinguir entre un puñado de órdenes.
D13. La presencia se deduplica; los botones, no
Al cablear el WebSocket, dos mensajes presence: false seguidos generaban
dos filas idénticas en el histórico. El estado no se rompía, pero el
histórico sí. La distinción quedó fijada en el código:
- Presencia es una señal de estado. «Sigue habiendo alguien» repetido cien veces no es información nueva. Solo se persiste cuando el valor cambia.
- Un botón es una acción discreta. Pulsar ❤️ dos veces son dos hechos; el segundo cuenta menos (rendimientos decrecientes), pero cuenta. Nunca se deduplica.
Importa desde ya porque el radar de presencia puede parpadear en el borde de su alcance y generaría cientos de filas basura por minuto.
D14. Se salta la v0 privada con botones
Decidida el 15 de septiembre, con el WiFi recién escrito. Revoca la parte operativa de D5: el razonamiento de validar barato antes de invertir en voz sigue siendo correcto en abstracto, pero se asume el riesgo y no se ejecuta. No se leen los botones PWR y BOOT, no se sueldan pulsadores, no hay convivencia previa de dos semanas.
Lo que se pierde, dicho explícitamente: el test barato de «¿engancha el
carácter?». Si algo no funciona después, no habrá manera de saber si es la
personalidad o el audio, porque las dos variables llegan a la vez. Aceptado a
sabiendas. Lo que no cambia: EmotionInput, press() y los rendimientos
decrecientes siguen siendo necesarios; solo cambia quién los llama.
D15. La voz: lo que se probó y lo que se aprendió
El 17 de septiembre se abrió la fase de voz con una sesión entera de TTS local. XTTS-v2 es clonado de voz, no voz de personaje: con una grabación mía como referencia, NOVA intentaba sonar como yo. Kokoro y Piper son rapidísimos pero ninguna voz en español encajaba. Chatterbox Multilingual V3 sí convenció en calidad, y tardaba unos 136 segundos en generar una frase corta en un M3 Pro.
El hallazgo no fue de velocidad sino de categoría: Chatterbox es autorregresivo, y eso es a la vez lo que le da la expresividad y lo que lo hace lento. Piper y Kokoro son otra familia, no autorregresiva, y por eso van dos órdenes de magnitud más rápido y suenan más planos. No hay ajuste que cierre esa distancia. Y un producto que necesita un M3 Pro para ir lento no tiene respuesta a dónde corre eso con diez unidades vivas.
La vía híbrida que salió de ahí (frases proactivas pregeneradas, conversación
por API) se ejecutó solo a medias, y no por el camino previsto. La voz que
convencía de verdad venía de ElevenLabs, y su política de uso prohíbe usar su
audio como entrada de otro modelo: la clonación en un TTS local se cerró por
contrato, no por técnica. Hume Octave dirige la entonación mejor que nada,
pero está entrenado para habla humana realista: pides un robot y te da una
persona interpretando a un robot. Al final, las 25 frases proactivas se
generaron con ElevenLabs y un efecto propio en Python (pyworld y
pedalboard) para darles timbre de robot pequeño: tono a 280 Hz, formantes
×1,28, entonación aplanada. La voz acabó siendo un preset de siete números
sobre una voz base, que es justo lo que haría viable «elige la voz de tu
NOVA» en una app.
La otra mitad, la conversación libre por API, quedó revocada por D16 sin haberse escrito una línea.
D16. NOVA no habla
El cambio de diseño más grande desde D7, decidido el 18 de septiembre. NOVA deja de ser una criatura que conversa y pasa a ser una criatura que gesticula, suena y obedece comandos concretos. Lo largo (conversación, preguntas, texto) se va a una app web, y más adelante móvil.
El problema de la sesión de voz no era técnico. Era una contradicción de diseño: una voz que suena humana obliga a juzgarla como habla humana. Cualquier acento raro, cualquier énfasis fuera de sitio, y la ilusión se cae. Un sonido no verbal no tiene ese listón: nadie compara un pitido con nada. Es el motivo por el que R2-D2 y WALL-E transmiten más que la mayoría de robots parlantes, y por el que Moflin, que no dice una palabra, agotó existencias.
Tres consecuencias que refuerzan la decisión:
- Los sonidos no tienen idioma. Se estaba a punto de duplicar 26 audios al inglés, y detrás venían francés y alemán.
- Desaparece el coste recurrente y la fricción de la clave de API, que era el techo real del producto: el 95 % de quien compre no se va a sacar una clave.
- Es la conclusión natural de D7. Quita el verbalizador y deja el corazón intacto.
La voz no se elimina: se vuelve escasa. NOVA gorgotea durante semanas sin decir una palabra, y un día, tras tres días de silencio, dice una sola frase. El contraste es el efecto: un objeto que nunca habla y de repente habla se recuerda. Sobreviven tres o cuatro de las 25 frases generadas: «Llevas tres días sin hablarme», «Lo sé», y como mucho una o dos más, a decidir conviviendo. Las otras se archivan, regenerables en minutos.
Lo que se cae del plan: el streaming bidireccional de audio con Opus, STT y TTS por API, el objetivo de latencia por debajo de 1,2 s, el micrófono siempre abierto y, con él, buena parte del problema de GDPR. Lo que se mantiene intacto: toda la máquina de estados, los ojos, el WebSocket, el histórico y el wake word en el dispositivo.
El riesgo que se acepta: el foso se mueve de sitio. Ya no es el texto de las frases, es cómo suena y cómo se mueve. Eso es diseño de sonido y de animación, terreno menos conocido que escribir frases.
D17. Balbucea: la voz es de criatura, no de persona
Decidida el 19 de septiembre, en el banco. D16 quitó el habla; D17 dice qué la sustituye. NOVA balbucea: una voz sintética de criatura pequeña, sierra aguda pasada por dos formantes, troceada en sílabas, con vibrato y cada sílaba un poco desafinada, que tiene entonación pero no tiene idioma. Lo que hacen los esbirros de Gru: se entiende todo y no dice nada.
No tiene el listón de D16, porque un balbuceo no se compara con nada, pero a diferencia del pitido suena a alguien. La primera prueba en el banco acabó pidiendo meterlo en más gestos. Y es aritmética: un oscilador, dos filtros de segundo orden, ruido blanco y envolventes. Cabe en el ESP32-S3 sin un solo fichero de audio, cosa que el 20 de septiembre quedó demostrada en la placa.
Las reglas que salen de aquí:
- Pitido es interfaz, balbuceo es criatura, y se mezclan a propósito. Solo tres gestos siguen siendo pitidos: el acuse del wake word, el golpe en la mesa y la cuenta atrás. Son NOVA como aparato, y conviene que suenen distinto de NOVA como bicho.
- La boca no es parte de la identidad. NOVA sigue siendo dos ojos; en todos los presets la boca está a cero. Aparece solo cuando un gesto la pide (bostezo, estornudo, hablar, caricia), porque ahí los ojos solos no distinguían un bostezo de un sobresalto.
- La cara entera se mueve. Tres canales de cuerpo por el mismo muelle que todo lo demás. Asentir bota, negar se balancea, pensar ladea, «te escucho» inclina la cabeza como un perro. Es lo que se lee desde el otro lado de la mesa, sin mirar los párpados.
- Dormida se deriva, no se guarda: párpados cerrados y ningún gesto en marcha. Con eso salen solos las «z» por ciclo de respiración y se bloquean los micro-gestos automáticos.
D18. Las actividades corren en la placa
Decidida el 19 de septiembre al definir las funcionalidades útiles: temporizador, concentración tipo pomodoro, alarma, respiración guiada, «¿qué suena?» y «¿sí o no?». Tres decisiones en una:
- Hace falta una capa nueva. Un temporizador no es una emoción ni un gesto: es un estado con tiempo que vive encima de la criatura sin sustituirla. NOVA sigue aburrida o contenta mientras cuenta; la actividad le presta la pose, la mirada y el ritmo mientras dura. Y corre en la placa, con reloj inyectado: una alarma que depende del WiFi no es una alarma. El backend guarda que ocurrió; la placa manda en el tiempo.
- La entrada es palabra fija más táctil más app, nunca transcripción. Alarma y temporizador necesitan números, y eso es vocabulario abierto; microWakeWord reconoce palabras fijas. Así que comandos de una palabra con valor por defecto («temporizador» son diez minutos), ajuste en la propia pantalla (deslizar ±5 min, tocar para confirmar) y la app para lo fino.
- «¿Qué suena?» sí, con AudD, y con reglas. Es la única funcionalidad que reintroduce audio saliendo del dispositivo y un servicio de pago. Se acepta porque cuesta 0,005 $ por consulta, con la clave en el backend y tope mensual por dispositivo (200 consultas son un dólar). Audio solo tras el comando explícito, diez segundos, no se guarda, y el gesto de escuchar es el aviso visible de que está grabando. Y antes que identificar, reaccionar: detección de pulso en la placa, sin nube, para que bote al ritmo.
Lo que sale de aquí para la pantalla redonda: un anillo de progreso por el borde del disco, compartido por temporizador, pomodoro, alarma y respiración, más los dígitos bajo los ojos. La criatura queda dentro del anillo, trabajando. Se descartan por ahora el calendario (OAuth, fricción) y los descansos por presencia (necesitan el radar).
Hardware
Waveshare ESP32-S3-Touch-AMOLED-1.75-B, comprada el 3 de septiembre de 2026 por 44,93 € con envío. Lo que importa de la ficha:
| Componente | Detalle |
|---|---|
| SoC | ESP32-S3R8, doble núcleo a 240 MHz, 8 MB de PSRAM y 16 MB de flash |
| Pantalla | AMOLED redonda de 1,75”, 466×466, táctil |
| Audio | Array de dos micrófonos con ES7210 (cancelación de eco por hardware); códec ES8311 y altavoz de 8 Ω 2 W incluido |
| Sensores | IMU de seis ejes y RTC con batería |
| Conectividad | WiFi 2,4 GHz y BLE 5 |
| I/O libre | Tres GPIO y un UART en un header de ocho pines; botones PWR y BOOT programables |

Al recibirla se comprobó lo que estaba apuntado como duda: el altavoz de 8 Ω
2 W viene en la caja con conector enchufable, el header de ocho pines viene
soldado (así que no hace falta soldador para empezar) y el cable USB-C tenía
líneas de datos, porque /dev/tty.usbmodem1101 apareció a la primera. El 20
de septiembre el altavoz sonó por primera vez, por I2S, con la síntesis
corriendo en el chip.
La restricción principal es que solo hay tres GPIO libres (16, 17 y 18 en el header) y un UART. Desde D12 y D14 pesa menos: sin botones en el producto ni en el prototipo, el presupuesto de pines queda para el radar de presencia, que va por ese UART. Vías de escape si hiciera falta más: el expansor I2C que trae la placa, los botones PWR y BOOT, y la propia pantalla táctil. Es el precio de la placa integrada: libertad de I/O a cambio de velocidad.
Lo explícitamente aplazado a la fase de producto: carcasa impresa propia (la placa trae una protectora), batería, el radar de presencia (buena idea, mala compra ahora) y la cámara (descartada).
Percepción sin cámara
El seguimiento del ratón en el banco de ojos sustituye a una sola cosa: «alguien está ahí». En la placa eso se cubre con cuatro sensores, tres de ellos ya soldados: la pantalla táctil (mira hacia donde la tocas), la IMU (sabe cuándo la coges o le das un golpecito en la mesa) y el array de micrófonos. Las reacciones a los tres ya están diseñadas como gestos; los drivers de la IMU y del táctil son trabajo de la etapa de la alarma.
Dirección del sonido
Reaccionar al sonido es lo que hacen los animales; para una criatura es más potente que seguir una cara. El componente correcto es ESP-APA-DOA, que estima el retardo entre los dos micrófonos por GCC-PHAT y saca el ángulo. Con la separación que cabe en 1,75” de diámetro la resolución es tosca: izquierda, centro, derecha y poco más. Y con eso sobra: la ilusión está en el timing de la sacada, no en la precisión.
Presencia por radar
El módulo LD2410 (radar de 24 GHz, 4–7 €) no es un PIR. Un PIR detecta cambios de calor, o sea movimiento: si te sientas a leer y te quedas quieto tres minutos, concluye que no hay nadie y NOVA se dormiría contigo delante. El radar detecta objetivos en movimiento y estáticos por separado, el estático por micromovimiento (respiración, ajustes de postura), con distancia y alcance de 5–6 m en puertas de unos 75 cm.
Lo importante no son los tres comportamientos que salen gratis con la distancia. Es que permite distinguir dos abandonos que no son lo mismo:
Estuviste una semana de viaje. No es culpa tuya.
Estuviste sentado delante cuatro horas al día y no le dijiste nada. Eso es otra cosa.
«You haven’t talked to me in 3 days» es un buen chiste. «You’ve been sitting right there» es mejor, y solo es posible si el dispositivo sabe que estabas. Eso convierte el sensor de adorno en parte del corazón del producto.
Detalles que ya están apuntados para no perder una tarde: va por UART y el header tiene exactamente uno libre; el baudrate por defecto es 256000, no 115200; ve a través del plástico, así que va dentro de la carcasa sin ventana, pero no a través del metal; se confunde con ventiladores y cortinas, y el tiempo se va en ajustar sensibilidades, no en el código.
Y una regla que salió de escribir el contrato entre firmware y backend: estar conectada a la WiFi no es que haya alguien delante. La placa no envía presencia a propósito, porque vive enchufada y arrancaría el contador de «ignorada con alguien delante» en falso. Presencia solo se envía cuando exista una fuente real: radar, táctil o IMU.
Arquitectura
Dispositivo (ESP32-S3) / cliente de pruebas HTML
| ojos · boca · cuerpo · altavoz · micro · táctil · IMU
v WebSocket ws://.../ws/creature?device=xyz
Backend (Java), probado contra el hardware
├── CreatureWebSocketHandler JSON a llamadas al dominio
├── CreatureSessionRegistry criaturas vivas, por dispositivo
├── CreatureTriggerScheduler cada 30 s, ¿algo que decir sola?
├── Creature (dominio puro) el corazón
├── CreatureEventStore histórico de eventos en Postgres
└── ProactiveAudioLibrary las 3 o 4 frases, por HTTPEl principio central
El estado vive fuera del modelo de lenguaje. El modelo solo lo verbaliza.
El estado son cuatro valores: afecto, energía, humor y última interacción. Las mecánicas: decaimiento con el tiempo, entradas que inyectan valores, y triggers proactivos, es decir, el dispositivo reacciona sin que le hables porque un temporizador se disparó.
Tras D16 el principio se cumple en su forma extrema: fuera del modelo, y sin modelo. El estado se expresa con cara, sonido y, muy de vez en cuando, una de las tres o cuatro frases grabadas. La frase «I know» solo tiene gracia si es verdad.
El lenguaje no verbal
Desde D16 esto es el trabajo de diseño principal, el equivalente de lo que fue el timing de los ojos.
La entonación comunica; el timbre solo identifica. Un sonido que sube es una pregunta. Que baja, decepción. Dos cortos y rápidos, alegría. Uno largo que se apaga, sueño. Son cuatro o cinco reglas, y si se respetan siempre, quien convive con NOVA las aprende sin darse cuenta en dos días. El contorno de tono es el mismo para un pitido y para una sílaba; lo que cambia con D17 es que ahora hay alguien detrás.
Tres decisiones de implementación:
- Los sonidos se sintetizan, no se graban. Un pitido es aritmética, no un archivo: ocupa cero flash y da variación infinita, que es lo que evita que suene a máquina repitiendo el mismo WAV.
- El sonido va siempre pegado a una animación. Un chirrido con los ojos comprimiéndose es una criatura. El mismo chirrido suelto es una notificación de móvil.
- Se prototipa en el propio banco de ojos, no en un banco de sonidos aparte. Con WebAudio dentro del banco, cada gesto lleva su sonido en la misma línea de datos, y la regla anterior se cumple por construcción: si el gesto se interrumpe, el sonido se corta.
Los gestos, en código
Secuencias, no poses: el carácter está en lo que hace entre expresiones. Cada gesto es una lista de fotogramas con su instante en milisegundos y, en cada uno, cualquier combinación de parámetros de pose, mirada (con opción de bloquearla), «z» que soltar, y al final un preset en el que acabar. Cada gesto lleva su sonido de forma obligatoria: un gesto sin sonido está a medio diseñar.
La pose tiene doce parámetros, todos por el mismo muelle: seis de párpados y pupila, tres de boca y tres de cuerpo (desplazamiento y ladeo). El banco exporta los 32 gestos, sus 149 fotogramas y los ritmos como una cabecera C que el firmware incluye tal cual; nada se copia a mano.
| Gesto | Lo dispara | Sonido | Cuerpo y boca |
|---|---|---|---|
| Sobresalto, doble toma, mirar y apartar, bostezo, pensar, estornudo | El temporizador de micro-gestos, solo | Gasp, «¿oh… eh?», «hmm», «aa-CHU» | Salta, se balancea, ladea; el bostezo abre la boca y estruja los ojos |
| Te levanta, mareo, golpe en la mesa | La IMU | «¡uiii!», «uoauo» cayendo, pitido | Sube con sonrisa abierta; tambalea ±8°; sacudida |
| Caricia, pantalla tapada | El táctil | Ronroneo; «mo» al destapar | Se recuesta con sonrisa cerrada; se encoge |
| Te escucho, entendido, no he entendido, está sonando, cuenta atrás | Un comando | Pitido, «ajá», «¿eh?» subiendo, balbuceo de cinco sílabas, pitido | Cabeza inclinada como un perro; bota dos veces; se balancea con mueca; la boca pulsa con las sílabas; vibra |
| Se duerme, despierta, estirarse | Un cambio de estado | «mh» que se apaga, «¿mm? ¡ah!», «a» subiendo | Se desploma y acaba en Dormida; se sacude y acaba en Neutral |
| Risita | Buen humor al preguntarle cómo está | «ihihi» | Bota con sonrisa abierta |
| Anotado, ¡hecho!, ¡suena!, ¡¡suena!!, cinco minutos más, a lo mío, mirada furtiva, racha, escuchando, ¡la tengo!, ni idea | Una actividad | Balbuceos con su contorno; la mirada furtiva y el foco, callados | Mira los dígitos y asiente; bota; ojos de par en par; gruñe con las cejas fruncidas; baja la vista a su mesa; te mira de reojo y vuelve |
| Chsss | El modo silencio | «h» bajando | Boca en línea, un guiño de cabeza |
Dormida no es un gesto: se deriva. Mientras dura, una «z» por ciclo de respiración y ningún micro-gesto automático. El ronquido suena una sola vez, en el primer ciclo tras dormirse, y luego se calla hasta la próxima vez: repetido en cada ciclo cansaba.
Un bug de fondo que se encontró de paso: un gesto que interrumpía a otro heredaba su pose. Caricia y acto seguido golpe dejaba la boca y los ojos de la caricia para siempre, porque al empezar un gesto se guardaba como pose de reposo el objetivo del momento, que era la pose del gesto anterior. Misma familia: cambiar de preset o mover un slider durante un gesto se deshacía al terminar. Regla que quedó: la pose de reposo se cambia por un único camino, que actualiza también la base del gesto en curso; un gesto que interrumpe a otro restaura primero esa base; y el sonido se corta con el gesto, o queda un ronroneo huérfano encima del golpe.
Las actividades, en código
Una actividad es un objeto con start, step y, si reacciona a toques,
touch; con constantes reales (las que se exportan al firmware) y
comprimidas para el banco (diez minutos son cuarenta segundos). Es lógica,
no datos: se reescribe en C con el mismo razonamiento, como el dibujo.
Lo que una actividad puede prestarle a la cara, y que se limpia entero al
terminar: una pose sujeta, la mirada, el ritmo (el temporizador pasa a
ALERT en el último 20 %), la respiración guiada, el silencio, la ausencia
de micro-gestos, y el anillo, los dígitos, la leyenda y un texto.
| Actividad | Flujo | Toque |
|---|---|---|
| Temporizador | Anotado → cuenta con el anillo vaciándose → ALERT en el último 20 % → cuenta atrás los últimos cinco segundos → suena, y a la quinta insistencia se pone pesada | Cualquiera lo para y celebra |
| Concentración | A lo mío: pose sujeta, mirada a su mesa, callada y sin micro-gestos salvo la mirada furtiva cada 5–9 s → estirón y descanso → ×4 → racha y «¡4 SEGUIDOS!» | Ninguno: no molesta, no se deja molestar |
| Alarma | «7:30» → se duerme, ronca, el anillo se llena hasta la hora → se despierta de golpe y suena como el temporizador | Caricia o toque: cinco minutos más, y vuelve a sonar. Levantarla: despierta y «BUENOS DÍAS» |
| Respira | Inspira 4 s, mantén 2, espira 6; el anillo se llena y se vacía, la cara respira ×3 con un aliento por fase → asiente | — |
| ¿Qué suena? | Te escucho → escuchando, con el anillo llenándose durante la grabación (el aviso visible) → ¡la tengo! y el título en pantalla, o ni idea | — |
| ¿Sí o no? | Pensar → asiente o niega. Cero gestos nuevos | — |
Dos modos sin tiempo: silencio (chsss, y a partir de ahí solo visual: el sonido se corta en un solo sitio, no en cada gesto) y música (doble toma y luego cabecea al tempo: baja de golpe en el pulso, vuelve despacio, se ladea alternando).
Las dos poses. Durante una actividad hay dos poses y no hay que mezclarlas: la que la actividad sujeta (foco mirando a su mesa) y el reposo de verdad, que es el humor. La primera versión lo mezclaba: cancelar la alarma a mitad del gruñido del snooze la dejaba dormida, y cambiar el humor durante el temporizador se perdía al acabar, porque volvía al humor de hace diez minutos. Regla: lo externo (presets, sliders, afecto; en la placa, el backend) actualiza siempre el reposo aunque no se vea; lo interno de la actividad nunca toca el reposo y muere con ella. Encontrado con un harness de estados con reloj controlado, no a ojo: 28 estados comprobados fotograma a fotograma, nueve escenarios de interrupción cruzada, y el sonido contado con un contador de osciladores (en silencio y en foco no se crea ninguno).
El dominio, en código
Módulo com.nova.creature.domain: Java puro, cero dependencias, ni una
anotación de Spring. Spring lo envuelve desde fuera; la lógica de
personalidad no sabe que existe un framework y los tests corren en
milisegundos.
| Clase | Papel |
|---|---|
Creature | La máquina de estados. Recibe un Clock en el constructor. |
Tuning | Todas las constantes de personalidad en un solo sitio. Es lo que viajará por OTA. |
Snapshot | Lo que se entrega al dispositivo: qué siente y a qué ritmo. |
EmotionInput, Expression, TimingPersonality, Trigger | Enumerados del dominio. |
Cinco decisiones que aguantan:
- Decaimiento exponencial continuo, no por pasos. Tres días en un salto dan exactamente lo mismo que 72 saltos de una hora, y hay un test que lo comprueba. Sin esa propiedad, un dispositivo que se desconecta una tarde vuelve con el estado mal, y ese bug es infernal de encontrar en la placa.
- El humor revierte hacia una línea base marcada por el afecto. El vínculo largo fija el estado de reposo; los eventos lo perturban. Es lo que hace que la criatura sea coherente consigo misma.
- Rendimientos decrecientes en las entradas. Una variable de saturación que decae. Sin esto, machacar ❤️ sube el afecto al máximo y la mecánica se nota falsa en diez segundos. Confirmado en uso real desde el navegador: de 55 a 75 y a 91,7 con las primeras pulsaciones, luego movimientos en décimas.
- La energía no se persiste: se deriva de la hora local. Función pura
del reloj, mínimo a las 4:00 y máximo a las 16:00. Y manda sobre el
humor: de madrugada la personalidad de timing es
TIREDsin excepción, aunque el humor esté en máximos. - El
Snapshotno devuelve geometría. Ni un ángulo de párpado ni una curva: devuelve qué siente y a qué ritmo. La forma vive en el firmware, y eso permite rediseñar la cara entera sin tocar la personalidad.
La ética, como constantes con test
La pregunta de «encantador o coercitivo» deja de ser una intención y pasa a
ser tres números en Tuning, cada uno con su test:
| Constante | Valor | Qué garantiza |
|---|---|---|
affectionRecoveryFactor | 2,5 | El afecto sube 2,5 veces más rápido de lo que baja. Volver siempre compensa más que irse. |
affectionFloor | 15 | Nunca llega a cero. No hay criatura muerta que dé culpa mirar. |
neglectCooldown | 24 h, sin escalado | Lo menciona como mucho una vez al día y nunca sube el tono. |
No es «lo dice una vez y nunca más», que sería peor: si te vas dos meses lo mencionaría el día tres y callaría para siempre. Es como mucho una vez al día, sin escalar. Si algún día alguien afloja esos tres números, que sea sabiendo lo que afloja.
Al escribir el contrato con el firmware apareció un trigger que rompe esa
garantía: LONELY no tiene cooldown propio, solo el general de dos horas.
Con el afecto por debajo del umbral de soledad podría dispararse doce veces
al día indefinidamente. Está apuntado y pendiente; el arreglo es el mismo
patrón que NEGLECTED.
Una línea temporal de referencia
arranque afecto= 55.0 humor= +0.0 NEUTRAL CALM
❤️ afecto= 75.0 humor= +30.0 HAPPY CALM
+2 h afecto= 74.5 humor= +19.9 NEUTRAL CALM
😡 afecto= 72.5 humor= -21.3 HURT CALM
+8 h afecto= 70.7 humor= +6.4 NEUTRAL TIRED <- el enfado se pasó solo
+3 días afecto= 56.4 humor= +2.5 NEUTRAL TIRED -> dispara NEGLECTED
+2 semanas afecto= 28.9 humor= -8.4 BORED TIRED
❤️ vuelve afecto= 48.9 humor= +21.6 HAPPY TIRED <- recuperación asimétricaPendiente de ajustar conviviendo con ella, no teorizando: ¿dos semanas de silencio deberían dejarla en 28? ¿Un solo ❤️ debería subirla de 28 a 48?
NEGLECTED se cobra cuando vuelves
Decidido el 18 de septiembre, al resolver un problema de la voz, y se mantiene con D16 porque afecta al dominio, no al audio.
El problema: a los tres días justos lo más probable es que no haya nadie delante, precisamente porque llevas tres días sin estar. Y como el firmware no envía presencia a propósito, el backend no puede saberlo. La frase saldría a una habitación vacía. El guion del vídeo ya lo decía sin decirlo: tú hablas primero, y NOVA responde con lo de los tres días. No es un temporizador que salta solo, es un temporizador que se cobra cuando vuelves.
Cómo queda: el scheduler deja de evaluar NEGLECTED. Cuando llega una
interacción, antes de actualizar la última, se calcula el tiempo
transcurrido; si supera el umbral, la interacción devuelve el aviso junto con
esa duración. Si se aplicara primero la interacción, el tiempo pasaría a ser
cero y la frase ya no tendría sentido. El dominio devuelve solo el hecho y la
duración; elegir si suena la variante con número (solo entre 72 y 96 horas) o
una sin número es cosa de la capa que selecciona audio.
No hace falta guardar estado «pendiente»: se deriva, igual que la energía se
deriva de la hora. Y tiene un efecto colateral bueno: así NEGLECTED no
puede saltar dos veces en 24 horas por construcción, porque la propia
interacción que lo entrega reinicia el contador.
El WebSocket y el contrato
El paquete com.nova.creature.ws es la frontera entre Spring y el dominio
puro, igual que persistence lo es para JPA. Tres piezas: el handler que
traduce mensajes JSON a llamadas del dominio y devuelve el Snapshot; el
registro de sesiones; y el scheduler que cada 30 s en desarrollo (minutos en
producción) pregunta a cada criatura conectada si tiene algo que decir sola.
El histórico solo se reconstruye una vez, al conectar, repitiendo los eventos guardados. A partir de ahí la criatura vive en memoria. Al cerrarse la última sesión se olvida, porque cada evento ya quedó persistido según ocurrió; la próxima conexión reconstruye desde cero.
La concurrencia es real, no teórica: Creature no es thread-safe, y no tiene
por qué serlo, pero el hilo que procesa una entrada y el hilo del scheduler
pueden tocar la misma criatura a la vez. Todo acceso pasa por
synchronized (creature).
El protocolo entre las dos piezas vive en un documento aparte, el contrato
firmware-backend, con cada afirmación apuntando al código que la garantiza.
Lo escribí el 15 de septiembre, antes de flashear, y ya entonces sacó a la
luz cosas que el código no decía: que la expresión LOVE es inalcanzable por
red (ningún camino del dominio la devuelve, porque el afecto es un vínculo
lento, no una cara), que sin entradas el humor solo puede estar en su línea
base y por tanto las únicas expresiones visibles en el primer corte serían
NEUTRAL, ASLEEP y BORED, y el cooldown que le falta a LONELY. La
versión 2, del 20 de septiembre, añade lo que pidió la etapa 2a:
- Varias sesiones por dispositivo. La placa y el cliente de pruebas se conectan con el mismo identificador y comparten la criatura. Antes la última en conectar pisaba a la anterior, y las frases proactivas iban al navegador en vez de a la placa.
statedifundido a todas las sesiones tras cada entrada, para que la placa reaccione al instante a lo que se pulsa en el cliente, sin esperar a su siguientesync.gesture, que el backend reenvía tal cual a todas las sesiones sin tocar la criatura ni el histórico, yforce, que fija expresión y ritmo para recorrer las ocho caras por cuatro ritmos en la placa sin tener que empujar el afecto hasta cada una. Es la única forma de verLOVE.speakya hace algo en la placa: lanza el gesto de hablar, con la boca pulsando con las sílabas.

Probar seis meses de abandono en milisegundos
Toda la lógica de personalidad depende del paso del tiempo. Como la fuente de tiempo se inyecta, los tests simulan tres días, una semana o seis meses de silencio sin esperar nada. Al añadir el histórico apareció un fallo interesante: el reloj de repetición nacía por defecto en 1970 hasta que alguien lo colocaba en una fecha. Se arregló quitándole al constructor la posibilidad de nacer sin saber cuándo. Los objetos que pueden nacer en un estado inválido acaban naciendo en un estado inválido.
El firmware, en código
Proyecto nova-firmware, sobre ESP-IDF v6.1. Cuatro dependencias y ni un
driver escrito a mano:
# main/idf_component.yml
dependencies:
waveshare/esp32_s3_touch_amoled_1_75: { version: "*" }
lvgl/lvgl: { version: "9.4.*", public: true }
espressif/esp_websocket_client: { version: "^1" }
espressif/cjson: { version: "*" }El hallazgo que cambió la fase 1: existe un componente gestionado oficial de
la placa, y lo resuelve todo con una llamada. bsp_display_start()
inicializa el display por QSPI, el táctil por I2C y arranca la tarea de LVGL;
bsp_audio_init() hace lo mismo con el códec y el altavoz. Eso era el 80 %
del trabajo temido de puesta en marcha, y no existe.
| Fichero | Papel |
|---|---|
main/main.c | Arranca la placa, la red y los ojos |
main/nova_eyes.c | La cara: geometría, párpados curvos, boca, cuerpo, parpadeo, sacadas, respiración, muelle y el secuenciador de gestos |
main/nova_bank.h | Generado desde el banco: tipos, presets, los 32 gestos y los ritmos. No se edita a mano |
main/nova_audio.c | La síntesis de voz y pitidos, en una tarea propia sobre I2S |
main/nova_wifi.c | Estación WiFi, credenciales desde NVS con Kconfig de respaldo, reconexión con backoff |
main/nova_net.c | Cliente WebSocket: sync cada cinco segundos, state, speak y gesture |
Kconfig.projbuild | URL del backend y credenciales: nada escrito en el .c, y el sdkconfig generado fuera de git |
partitions.csv | Dos ranuras de app más otadata y 4 MB de almacenamiento: preparada para OTA |
Cómo se dibuja un ojo. No con widgets de LVGL sino escribiendo el buffer
del canvas directamente, por tramos horizontales (fuera, rampa de antialias,
sólido, halo, pupila), y evaluando coma flotante solo en los dos a seis
píxeles de transición de cada fila, no en los 23.000 del canvas. El párpado
se resta del cuerpo escribiendo el canal alfa a mano, con antialias por
cobertura parcial: es el equivalente exacto del destination-out del banco,
no una aproximación. El canvas es RGB565A8: color en 16 bits y alfa aparte,
la mitad de PSRAM que ARGB8888 y sin conversión de formato al volcar.
Los párpados curvos (16 de septiembre) parecían pedir polígonos, y LVGL 9 no tiene primitiva de polígono. La salida fue darse cuenta de que la Bézier cuadrática del banco, con los dos extremos a la misma altura, es una parábola pura: se resuelve como fórmula cerrada por columna, sin teselar. Y una decisión deliberada: el factor de vano del banco (2,6) no se copió. Con 2,6 el parámetro de arco es casi todo desplazamiento y apenas curvatura, porque el banco lo necesita así para que el párpado siga cubriendo tras rotarlo; portado literal, la risa seguía saliendo como banda plana. Se bajó a 1,05 para que hubiera arco de verdad, verificado renderizando ambas opciones antes de flashear, no a ojo en la placa.
Boca y cuerpo (20 de septiembre). La boca es un tercer canvas, dos cúbicas rellenas por línea, y solo se rasteriza cuando un gesto la pide. El cuerpo desplaza los canvas enteros, como ya hacía la respiración; el ladeo no rota bitmaps (rotar dos canvas por software cada fotograma hundiría los FPS): un ojo sube y el otro baja, y los párpados se sesgan, que es cómo se componen las rotaciones en el banco.
Rendimiento: 61 FPS reales, medidos en la placa. El benchmark de 49 FPS
del 14 de septiembre era del demo de Waveshare, nunca se había medido la
cara en sí. Al pedir un efecto de resplandor el 17 se descubrió que iba a
21 FPS, sin margen ninguno. El camino, medido con esp_timer en cada
paso: 17 ms por ojo de línea base, de los que 11,7 eran tres lv_draw_rect
con máscaras de radio; rasterizar a mano, 8 ms y 34 FPS; de píxel a píxel a
tramos por fila, 3,6 ms y 51 FPS; invalidar solo el rectángulo del canvas con
contenido real, 61 FPS. El techo es el propio tick de 16 ms (62,5 FPS), no el
dibujo. Antes de encontrar la causa real se descartaron con medición tres
hipótesis plausibles: que el volcado de LVGL fuera caro, que la PSRAM fuera
lenta y que fuera la caché de instrucciones. Y cada versión del rasterizador
se comparó píxel a píxel en Python contra la anterior antes de flashear: cero
píxeles distintos en 192 combinaciones.
El resplandor se descartó. Implementado, probado en la placa, y fuera: bajaba de 61 a 20 FPS porque el área sucia de los dos ojos se funde en un rectángulo mucho más grande, y sin las scanlines que lo acompañan en el banco se veía falso. Queda tras un flag a cero por si algún día se retoma por otra vía.
Audio (20 de septiembre). Una tarea propia sintetiza el balbuceo completo
de D17 (sierra con vibrato, dos biquads formantes en paralelo, ruido de
aliento, golpe de consonante, envolvente por sílaba, tremolo del ronquido) y
lo escribe al códec ES8311 por bloques de 128 muestras a 22.050 Hz. Medido
con esp_timer: unos 18 µs por bloque en reposo, el 0,3 % de un bloque de
5,8 ms; pico del 57,7 % durante una reconexión WiFi. Los ocho contornos de
pitido y el balbuceo con vocales elegidas están portados; cada gesto dispara
su sonido al arrancar y el nuevo corta al anterior.
Concurrencia en la placa. El módulo de red nunca toca LVGL: publica en
colas de longitud uno con semántica de sobrescritura, y el tick de los ojos
las vacía dentro de la tarea de LVGL. Son dos colas, estado y gesto, no una:
si llegan los dos entre dos ticks, con una sola el segundo pisaría al
primero. El sonido va por una tercera. Es el mismo razonamiento que el
synchronized del backend, y deja nova_eyes.c sin enterarse de que existe
la red.

Verificar sin placa y sin mirar. El banco se verifica en Chrome headless
con una copia instrumentada que parchea performance.now y
requestAnimationFrame con un reloj controlado, para capturar el instante
exacto de cada gesto y disparar los sonidos volcando los errores al DOM. El
--virtual-time-budget de Chrome no sirvió: doce capturas a distintos
presupuestos salieron todas en el primer fotograma.
Economía
| Concepto | Valor |
|---|---|
| Precio objetivo | 69–99 € |
| Lista de materiales ×10, pantalla IPS | ~44 € |
| Lista de materiales ×10, AMOLED | ~66 € |
| ×200–500 con PCB propio, IPS | 22–28 € |
Con AMOLED a 89 € el margen es fino, pero el producto es visiblemente mejor.
El coste que falta en casi todos los análisis es el recurrente: 2–6 € por unidad y mes de inferencia. Es la variable que separa lo que escala de lo que se convierte en un pasivo. D6 lo trasladaba al usuario; D16 lo hizo desaparecer, porque el dispositivo ya no hace inferencia. El único servicio de pago que queda es la identificación de música, a 0,005 $ por consulta y con tope mensual: el producto lo absorbe.
Otra métrica que casi nadie mira: la velocidad de clonado. Para un producto tipo tarjeta NFC, tres días. Para NOVA, meses, y aun así no te copian el personaje.
Trampas: las evitadas y las que pasaron de verdad
En el firmware
-Wno-error=attributesenmain/CMakeLists.txtes obligatorio, no cosmético. Esta sí pasó. Al simplificar el CMakeLists del ejemplo de Waveshare quité ese flag; conCONFIG_LV_ATTRIBUTE_FAST_MEM_USE_IRAMactivo, LVGL marca funciones para IRAM y GCC 15 (ESP-IDF v6.x) detecta secciones contradictorias enlv_color.cy aborta el build. Corolario: los ejemplos de Waveshare llevan flags raros por algún motivo, y no se limpian sin entender qué hacen.- El componente
jsondel core se eliminó en ESP-IDF v6.0.cJSON.hsigue funcionando igual, pero hay que traerlo comoespressif/cjsonen el manifiesto; sin eso,cJSON.h: No such fileal compilar el cliente de red. Kconfig.projbuildno se autogenera ni avisa si falta. Si el menú del proyecto no aparece enidf.py menuconfig, lo primero es comprobar que el fichero existe enmain/, y tras crearlo hace faltaidf.py reconfigure.- Cambiar
partitions.csvexigeidf.py erase-flashantes del primer flasheo con la tabla nueva, o el bootloader puede leer unaotadatacon basura de la tabla anterior. La tabla original, con 8 MB de app de fábrica, no admitía OTA: dos ranuras másotadatano cabían en 16 MB. Se cambió cuando era barato, con la placa aún en la mesa y flasheando por cable. - Un error de DMA que parecía benigno y no lo era.
Failed to allocate priv TX bufferapareció como pico puntual el 16 de septiembre. La causa real: los buffers de LVGL vivían en PSRAM y el driver del display no lee de ahí directo, así que por cada tira reservaba un buffer intermedio en RAM interna, lo copiaba y lo liberaba; bajo tráfico de WiFi esa RAM se fragmentaba. Resuelto de raíz moviendo las tiras a RAM interna fija (22 KB reservados antes de que arranque el WiFi): cero copias, cero errores en cuatro arranques completos. - Las tareas de WiFi viven en el núcleo 0. Pasó de verdad el 20 de
septiembre: la tarea de audio, creada sin afinidad, compitió con la
asociación WiFi y con LVGL en el núcleo 0; el hilo ocioso no corrió cinco
segundos y saltó el watchdog con LVGL atascado dibujando un ojo. Arreglo:
la tarea de audio fijada al núcleo 1, y fuera los
sinf,cosfypowfpor muestra (vibrato y tremolo pasan a rotar un vector unitario). El coste en reposo bajó de 30 a 18 µs por bloque y el pico en reconexión de 276 % (desbordado) a 57,7 %. Si los ojos dan tirones al conectar, mirar primero qué tarea nueva ha caído en el núcleo 0. - Subir el volumen por los números no funcionó. La simulación decía que
el sonido más fuerte llegaba a 0,23 de la escala y que los zumbidos salían
cinco o diez veces más flojos; se subió la ganancia maestra y se añadió una
por sonido. En la placa sonaba horrible y se revirtió entero. El pico
digital no es el límite: el amplificador a 5 V y el altavoz de 8 Ω en caja
distorsionan mucho antes. Y
esp_codec_dev_set_out_vol(70)no es un 70 %: la curva por defecto mapea 0 a −50 dB y 100 a 0 dB, así que 70 eran −15 dB sin querer. CONFIG_ESPTOOLPY_FLASHSIZE_16MB. Elhello_worldpor defecto asume 2 MB y avisa de la discrepancia; sin esa línea se desperdician 14 de los 16 MB.- Cable USB-C sin líneas de datos. La mitad de los cables son solo de carga: la placa enciende, el ordenador no la ve y pierdes una hora pensando que está muerta. Evitada: el cable usado sí los tenía.
- ESP-IDF v6.1 no está validada por Waveshare (solo la 5.5.5 y la 6.0.2). Se probó la opción arriesgada primero y compila; no hizo falta bajar de versión.
- PSRAM de 8 MB o no empieces. La «R8» del número de pieza. Verificado antes de comprar; también se detectó un listado que era en realidad un RP2350 sin WiFi disfrazado.
En el backend
- Spring Boot 4 usa Jackson 3:
databindse movió atools.jackson(asText()pasa aasString()), pero las anotaciones no:@JsonIgnorePropertiesy compañía siguen encom.fasterxml.jackson.annotation, ytools.jackson.annotationno existe. - Los rendimientos decrecientes son fuertes a propósito. Con una semivida
de estimulación de 20 minutos, una ráfaga de clics amortigua rápido (÷11 al
décimo, ÷21 al vigésimo). No es un bug: está en
Tuning, y se confirmó en uso real.
En la voz
- El TTS local que suena bien es demasiado lento, y el rápido no suena como se quiere. No es cuestión de ajustes sino de arquitectura del modelo (ver D15).
- La política de uso de ElevenLabs prohíbe usar su audio como entrada de otro modelo. Cierra la clonación de una de sus voces en un TTS local por contrato, no por calidad. Y el plan gratuito no da derechos comerciales sobre el audio.
- Hume Octave no hace timbres sintéticos. Está entrenado para habla humana realista: por mucho robot que pidas, da una persona interpretando a un robot.
- El ronquido a 170 Hz sonaba a serpiente. El segundo formante de la «z» estaba a 4.200 Hz a propósito; al oírlo de verdad era un silbido. Se bajó hasta que la fundamental cayera dentro del primer formante, medido con una simulación en Python del mismo algoritmo, no a oídas, y de paso salió un bug real: el desafine por sílaba se recalculaba en cada muestra en vez de una vez por sílaba. El timbre se dejó como está, a propósito: tras dos rondas seguía sin sonar a ronquido y no es un sonido prioritario.
En el banco
- Un gesto que interrumpe a otro heredaba su pose, y una actividad mezclaba su pose sujeta con el reposo. Las dos lecciones están en la sección de arquitectura; las dos las encontró un harness, no un ojo.
--virtual-time-budgetde Chrome headless no avanza animaciones porrequestAnimationFramede forma fiable. Lo que funciona es parchear el reloj.
Para el PCB propio
Condensador de 470–1000 µF cerca del amplificador (el pico al arrancar el altavoz hunde la línea y el ESP32 se reinicia; es el bug número uno de los primeros proyectos con audio), resistencias de 5,1 k en CC1/CC2 del USB-C, nada de AMS1117, y silenciar el amplificador cuando no reproduce para evitar el «pop».
Hoja de ruta
| Fase | Qué | Puerta |
|---|---|---|
| 0. Preparación | Ojos en el navegador, máquina de estados, WebSocket, toolchain | Cerrada |
| 1. Primer contacto | Placa verificada, ojos parpadeando, WiFi y WebSocket validados en la placa, párpados curvos, brillo especular, 61 FPS | Cerrada el 16 de septiembre |
| 2. La criatura reacciona | Antes «Voz». Gesto, sonido y comandos, en siete etapas | En curso |
| 3. El vídeo | Decidir el titular, ensayo de tres días encendida, grabar, publicar, lista de espera | Fase 2 funcionando |
| 4. Producto | App web y después móvil, carcasa, PCB propio, radar, primeras diez unidades | Demanda demostrada |
La fase de convivir dos semanas con botones se eliminó por D14; se mantiene tachada en el documento de trabajo, no borrada, para que quede constancia de qué se decidió no hacer y por qué. El razonamiento que la justificaba (el 80 % de estos proyectos mueren porque su propio creador deja de hablarles al cuarto día) sigue siendo cierto. Se acepta ese riesgo sin haberlo medido.
Las etapas de la fase 2
El orden es de dependencias, no de ganas. Cada etapa deja algo verificable antes de la siguiente, y no se abre una sin cerrar la anterior en el documento de trabajo.
| Etapa | Qué | Estado |
|---|---|---|
| 0. El banco | Capa de actividad, anillo, dígitos y los gestos de cada funcionalidad, verificados en headless | Cerrada el 19 |
| 1. Sonido en la placa | I2S, síntesis en C, altavoz | Cerrada el 20 |
| 2a. La criatura entera en la placa | 32 gestos, boca, cuerpo y sonidos, disparables desde el cliente | Cerrada el 20 |
| 2b. Temporizador de punta a punta | Máquina de actividad en C con reloj inyectado, wake word más táctil, evento activity en el contrato | Siguiente |
| 3. Concentración, silencio, respira | Pomodoro sobre la misma máquina, racha de días en el backend ligada al afecto | — |
| 4. Alarma | RTC y NTP en la placa, la app web mínima nace aquí, drivers de IMU y táctil | — |
| 5. Música | Detección de pulso en la placa, «¿qué suena?» con AudD | — |
| 6. Extras baratos | ¿Sí o no?, el tiempo por la mañana, agua, buenas noches, lluvia sintetizada | Cuando apetezca |
La etapa 2a no estaba en el plan del 19: la pedí yo el 20, antes de meter wake word y actividades, para que todo lo que ya hacía el banco corriera en la placa y pudiera comprobarlo a mano. Verificado esa noche: los 32 gestos con boca, cuerpo y sonido, la mayoría a 59 FPS con picos de 61 y caídas puntuales a 52–55 en los gestos de cuerpo, que mueven los canvas enteros. Se acepta así de momento.
Lo que queda de la fase 2 después de las etapas: mirada hacia la voz con el
array de micrófonos, el wake word «NOVA», decidir de dónde vienen las
emociones ahora que no hay voz que clasificar, NEGLECTED entregado en la
siguiente interacción, y las tres o cuatro frases en español e inglés.
Preguntas abiertas
- ¿De dónde vienen las emociones sin conversación? D14 asumía clasificación de sentimiento sobre la voz, que ya no existe. Candidatos: comandos reconocidos, caricias en el táctil, cogerla, presencia. Hay que decidirlo explícitamente, no por omisión.
- ¿Cuál es el titular del vídeo? «I built a ChatGPT pet» ya no describe el producto. Candidato desde D18: la mascota que trabaja contigo.
- ¿Sirve microWakeWord también para los comandos, o hace falta reconocimiento aparte? ¿Cómo se porta en español? ¿Y cuántos modelos aguanta la placa a 60 FPS? D18 asume diez o doce palabras fijas; no está medido.
- ¿La alarma suena en modo silencio? En el banco, silencio es silencio, pero una alarma que no suena no es una alarma.
- ¿En foco se ignoran los toques del todo, o una caricia cuenta como pausa?
- ¿Qué pasa con el afecto durante un pomodoro? Ignorarla no debería costar, y completarlo quizá debería sumar.
- ¿
ALERTen el último 20 % del temporizador o en los últimos dos minutos fijos? Con diez minutos es lo mismo; con una hora, doce minutos de tensión parecen muchos. - ¿Boca también en algún preset? Hoy solo en gestos, a propósito. Risa y Contenta son los candidatos.
- ¿Cuántas frases de voz sobreviven exactamente? Tres o cuatro, a decidir conviviendo.
- ¿Un ojo gigante o dos ojos dentro del círculo? Hay una placa con dos pantallas redondas que abre una tercera dirección.
- ¿Se guardan transcripciones? Tras D16 la pregunta se estrecha: en el dispositivo no hay nada que transcribir, así que solo aplica a lo que se escriba en la app. El audio de «¿qué suena?» no se guarda; solo el resultado.
- ¿Nombre definitivo? NOVA es el de trabajo.
Lo que ya está hecho
- 20 de septiembre. Etapa 1 cerrada: el altavoz suena por I2S con la
síntesis de D17 portada a C y medida; el watchdog saltó una vez por la
tarea de audio sin afinidad y se arregló. Etapa 2a cerrada la misma noche:
el banco exporta la cabecera del firmware, boca, cuerpo y secuenciador de
gestos en la placa, ocho contornos de pitido más el balbuceo, mensajes
gestureyforce, varias sesiones por dispositivo, contrato a v2. Verificado en la placa. - 19 de septiembre. D17: el banco de ojos pasa a ser banco de gestos, con WebAudio. Balbuceo, boca, cuerpo, «z» y sueño derivado; 20 gestos, luego 32 con las actividades. Bug de la pose heredada arreglado. Por la tarde, D18 y la etapa 0: la capa de actividad entera en el banco, con anillo y dígitos, verificada con un harness de 28 estados que encontró el bug de las dos poses. Plan de la fase 2 en siete etapas.
- 18 de septiembre. Las 25 frases generadas con ElevenLabs y un efecto
propio, tras descartar Chatterbox por la política de ElevenLabs y Hume por
timbre. Paquete
com.nova.creature.audioconstruido: biblioteca, selección sin repetición por bolsa barajada, servido por HTTP. Decidido queNEGLECTEDse entrega en la siguiente interacción. Y D16: NOVA no habla. - 17 de septiembre. Por la mañana, de 21 a 61 FPS con medición en cada paso, el error de DMA resuelto de raíz y el resplandor probado y descartado. Por la tarde, sesión de TTS local (XTTS, Kokoro, Piper, Chatterbox), D15 y las 25 frases escritas en español, con tono cálido pero contenido y sin una gota de reproche.
- 16 de septiembre. Primer flasheo con éxito de principio a fin: la placa
conecta, abre el WebSocket y la cara obedece al estado real. Validado
pulsando ❤️ desde el cliente con el mismo identificador que la placa.
HAPPYyLAUGHconfirmados alcanzables. Tabla de particiones con OTA. Por la tarde, párpados curvos como parábola cerrada, brillo especular con arrastre, halo de iris y paralaje del globo. Fase 1 cerrada. - 15 de septiembre. WiFi y cliente WebSocket escritos (credenciales desde
NVS con Kconfig de respaldo, reconexión con backoff,
synccada cinco segundos, sinpresencea propósito); los ritmos pasan de macros a tabla en tiempo de ejecución; cola thread-safe entre la red y los ojos. Escrito el contrato firmware-backend, que sacó a la luzLOVEinalcanzable y el cooldown que le falta aLONELY. D14: se salta la v0 con botones. - 14 de septiembre. Placa recibida y verificada. Demo de fábrica
comprobada, componente gestionado descubierto, ESP-IDF v6.1 compila los
ejemplos, benchmark de LVGL a 49 FPS. Proyecto
nova-firmwarecreado. Los ojos parpadean en la placa real. Build roto y arreglado por-Wno-error=attributes. - 6 de septiembre. Tests convertidos a JUnit 5 (el
main()de antes era invisible para el build: un falso verde); histórico de eventos en Postgres con reconstrucción por repetición verificada; WebSocket construido y probado; ESP-IDF v6.1 instalado yhello_worldcompilado para ESP32-S3. - 5 de septiembre. Cambio de fondo: activación por voz, no botones. Hoja de ruta reordenada.
- 4 de septiembre. Banco de ojos v1 y v2 en canvas; máquina de estados escrita y en verde; descartada la cámara; documentada la percepción.
- 3 de septiembre. Idea seleccionada entre 34; placa comprada; decididos placa integrada, máquina de estados sobre LLM y v0 sin voz.
Notas de este proyecto
- El estado vive fuera del modelo: cómo una mascota de escritorio sabe que llevas tres días sin hablarle
Un LLM con memoria da un chatbot amable. Una máquina de estados con reloj inyectado da una criatura. La frase «I know» solo tiene gracia si es verdad.