¡Buenas de nuevo, chefs del software! En el último post montamos nuestro servidor musical privado con Navidrome y elegimos cubiertos para el móvil y el PC. Pero os prometí un postre de alta cocina, de esos que requieren esferificaciones y nitrógeno líquido.

Hoy os traigo NaviPort, el cliente ultraligero que he programado desde cero para llevarme toda mi música en streaming directamente a mis consolas portátiles (Anbernic y demás bichos) bajo el sistema Knulli y la plataforma PortMaster.
Knulli wiki

Ultimas releases
PortMaster
¿El reto? Hacer que un dispositivo pensado para emular la Game Boy reproduzca música por streaming, use la pantalla y los botones nativos sin entorno gráfico de escritorio, no se quede colgado por falta de RAM y no se duerma a los cinco minutos.
¡Agarrad el código que encendemos los fogones avanzados!
La Receta Base: Los ingredientes de NaviPort
Para que el plato funcione en un entorno tan hostil (y ligero) como el de PortMaster, no podíamos meter un navegador web ni Electron. Sería como intentar meter un pavo de Navidad en un microondas. Así que elegimos ingredientes puros:
- Pygame: Nuestro lienzo para pintar la interfaz directamente en el Framebuffer (sin ventanas de Windows o X11).
- MPV (Nativo de Linux): El encargado de masticar el audio por streaming.
- API de Subsonic/Navidrome: El lenguaje en el que hablamos con nuestro servidor.
Los Secretos del Chef: Destripando el código (main.py)
Hacer que esto funcione fino tiene truco. Vamos a ver las técnicas de cocina arquitectónica más importantes que metí en el código para que ruede perfecto.
1. El truco del pinche asíncrono (Evitando que la pantalla se congele)
Si intentas descargar la carátula de un disco o pedir la lista de canciones en el mismo hilo donde pintas los gráficos, la pantalla de tu consola se congelará durante un segundo. En los videojuegos (y en las interfaces fluidas), eso es un pecado mortal.
Para solucionarlo, implementé un Motor Asíncrono usando hilos secundarios (Daemon Threads):
Python
def iniciar_carga_background(funcion_fetch, args, target_state):
t = threading.Thread(target=worker_carga_datos, args=(funcion_fetch, args, target_state), daemon=True)
t.start()
Mientras el hilo principal sigue pintando la pantalla a unos estables 30 FPS, un hilo "pinche de cocina" va por detrás pidiendo los datos a Navidrome. Cuando termina, avisa al jefe de cocina cambiando el estado a done y los datos aparecen mágicamente.
2. Comunicación por Sockets UNIX: El control milimétrico de MPV
Para no reinventar la rueda del streaming y la decodificación de audio, delegué esa tarea en mpv. Pero, ¿cómo controlamos desde Python si el usuario pulsa el botón de pausa o el volumen?
Aquí entra la comunicación IPC (Inter-Process Communication) mediante sockets UNIX:
Python
def enviar_comando_mpv(comando):
if not os.path.exists(MPV_SOCKET): return None
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(0.1)
s.connect(MPV_SOCKET)
msg = json.dumps({"command": comando}) + "\n"
s.sendall(msg.encode('utf-8'))
# ... lectura de la respuesta ...
Cada vez que pulsas el Botón X (Pausa) o los Gatillos L1/R1 (Volumen), nuestro script abre una tubería invisible hacia el proceso de mpv (/tmp/naviport_mpv_socket), le pasa un JSON con la orden en milisegundos y cierra la conexión. Así conseguimos que el HUD inferior de la pantalla muestre el progreso de la canción en tiempo real con precisión de cirujano.
3. Dieta estricta para la RAM: Purgado FIFO contra el OOM Killer
Las consolas retro baratas van justitas de memoria (algunas solo tienen 1GB de RAM). Si empiezas a navegar por tu biblioteca descargando carátulas de discos en alta resolución a lo loco, la RAM se llenará y el asesino del kernel de Linux (el OOM Killer) vendrá a cerrar tu app de golpe.
Para evitarlo, las imágenes se descargan directamente en un búfer binario en memoria y pasan por un filtro de purgado FIFO (First In, First Out) limitado a 20 elementos:
Python
if len(cover_cache) >= MAX_CACHE_SIZE:
cover_cache.pop(next(iter(cover_cache)))
cover_cache[a_id] = surf
Si la caché llega a 20 imágenes, NaviPort olvida la carátula más vieja para dejar sitio a la nueva. Consumo de RAM plano y estable como una roca, sin importar si tienes 10.000 álbumes en tu servidor.
4. Adaptación visual a ciegas: Framebuffer y Proporciones Dinámicas
Cuando programas para PortMaster, te encuentras con un problema: un usuario puede abrir tu app en una pantalla 4:3 de 3.5 pulgadas (como una Anbernic RG35XX H con resolución 640x480) o en una pantalla 16:9 más grande (como una Trimui Smart Pro a 1280x720). Si fijas los píxeles a mano, la comida se te deforma.
Para solucionarlo, en lugar de usar un entorno de ventanas tradicional, NaviPort interroga directamente al hardware al arrancar:
Python
screen_info = pygame.display.Info()
WIDTH, HEIGHT = screen_info.current_w, screen_info.current_h
A partir de ahí, toda la interfaz se dibuja mediante porcentajes y proporciones dinámicas. Las fuentes cambian de tamaño según el alto (int(HEIGHT * 0.05)), el HUD se adapta al fondo y las carátulas se reescalan solas a int(HEIGHT * 0.4). Da igual la pantalla que le eches; el plato se emplata solo y encaja al milímetro.
5. El motor "Marquee" para textos extralargos
A todos nos pasa: tienes una canción con un título larguísimo o un nombre de colaboración que no cabe en la pantalla. Lo normal en interfaces perezosas es cortar el texto con tres puntos (...) y olvidarse. Pero en una pantalla de 3.5 pulgadas, eso te deja sin saber qué estás escuchando.
Para solucionarlo, añadí el Marquee Engine 2.0, un renderizado cíclico que hace que el texto se desplace de forma fluida:
Python
def animar_texto_largo(texto, ticks, max_caracteres=30):
if len(texto) <= max_caracteres:
return texto
texto_con_espacio = texto + " "
desplazamiento = (ticks // 10) % len(texto_con_espacio)
res = texto_con_espacio[desplazamiento:] + texto_con_espacio[:desplazamiento]
return res[:max_caracteres]
Gracias al bucle principal de Pygame que corre a 30 FPS, calculamos el desplazamiento del texto basándonos en los ticks de animación. Si el título es largo, empieza a desfilar por la pantalla con un espaciado limpio tanto en la lista de reproducción como en el reproductor inferior del HUD. Un detalle estético que le da ese toque profesional de "reproductor comercial".
✅ Zero-Write: Protegiendo la SD del desgaste y la corrupción
Como chefs de sistemas, sabemos que una de las piezas más delicadas de nuestras consolas retro portátiles es la tarjeta MicroSD. Si la sometemos a un tueste continuo de escrituras (caché de imágenes, archivos temporales, logs de lectura), los sectores de almacenamiento se degradan rápidamente, provocando cuellos de botella y, finalmente, la temida corrupción de datos.
Por eso, el plato fuerte de la arquitectura de NaviPort es su diseño "Zero-Write". El software no realiza ninguna escritura en la tarjeta SD durante su ejecución. Todo el ciclo de vida de los datos ocurre en la memoria volátil (RAM):
- Carátulas y Datos API: Las llamadas HTTP se guardan en variables temporales. Como vimos antes, las imágenes se descargan directamente a un búfer en RAM (io.BytesIO) y se procesan al vuelo. El sistema FIFO integrado destruye los datos antiguos de la RAM al alcanzar las 20 carátulas.
- Streaming de Audio: Los parámetros enviados a
mpvfuerzan el uso estricto de memoria caché (--cache=yes y 8MiB de límite), evitando que el reproductor genere archivos temporales físicos del streaming en la SD. - Socket de Control: Incluso el archivo de comunicación,
/tmp/naviport_mpv_socket, reside en el directorio temporal, que en sistemas como Knulli, ArkOS o Batocera está montado nativamente comotmpfs(un disco virtual en la memoria RAM).
Al haber diseñado esta arquitectura, garantizas que el programa rinda al máximo sin cuellos de botella de I/O y proteges por completo la integridad de la SD de la consola. ¡Un emplatado seguro y profesional!
El Emplatado Final: Controles Universales
Para rematar el software, implementé un sistema de mapeo tolerante con SDL2. No importa qué consola uses, NaviPort lee el layout físico de forma universal:
- 🎮 Cruceta: Moverse por las listas (si la mantienes pulsada, acelera el scroll).
- 🟢 Botón A: Seleccionar / Reproducir.
- 🔴 Botón B: Volver atrás (gracias a una pila
nav_stackque recuerda tu posición exacta y nivel de scroll). - 🟡 Botón Y: Activar el modo aleatorio (Shuffle inteligente).
- 🛑 SELECT + START: Salida limpia del programa. Cierra el socket UNIX, mata a
mpvde forma segura para liberar la tarjeta de sonido (ALSA) y te devuelve al menú de Knulli sin colgar la consola.
Descargas:
Conclusión
Desarrollar este cliente me ha demostrado que, combinando un poco de ingenio, Python y la ayuda de una IA para estructurar las partes más tediosas del código, se pueden crear herramientas nativas brutales para dispositivos limitados.
El código fuente completo es totalmente abierto y podéis meterlo en vuestra carpeta roms/ports/ editando simplemente el config.json con vuestra IP y token de Navidrome.
¿Qué os parece este postre tecnológico? ¿Os animáis a probarlo en vuestras Anbernic? ¡Os leo en los comentarios!

