# Arquitectura Esta página es para quien vaya a **tocar el código** (o para uno mismo dentro de seis meses). Explica cómo está montado FOSFENO, dónde vive cada cosa y cómo se añade algo nuevo sin romper el resto. ## Las tres piezas ``` ┌───────────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────────┐ │ PANEL │ ────────────▶ │ SERVIDOR │ ────────────▶ │ ESCENARIO │ │ web/panel/ │ ◀──────────── │ backend/ │ ◀──────────── │ web/stage/ │ │ (el móvil) │ estado │ server.py │ estado │ (Chromium) │ └───────────────┘ └───────┬────────┘ └──────┬───────┘ │ subprocess │ HDMI ▼ ▼ projectM (nativo) [ proyector ] ``` - **Servidor** (`backend/server.py`, Flask + Flask-SocketIO). Guarda **el estado**, sirve los archivos web, lanza y vigila projectM, enruta el audio con `pactl` y persiste lo que hay que persistir. - **Panel** (`web/panel/`). La interfaz del móvil. **No calcula nada**: refleja el estado y manda órdenes. - **Escenario** (`web/stage/`). La página que sale por el proyector. Es la que **de verdad pinta**: Butterchurn, Hydra, Shaders y el Mezclador viven aquí, igual que el detector de BPM y la captura de audio. Regla de oro: **el estado vive en el servidor**, y todo el mundo lo recibe entero en cada cambio. Ni el panel ni el escenario guardan verdades propias; si algo hay que recordar, va al estado. ## El estado Es un único diccionario en `server.py` (`state`) que se difunde con `socketio.emit("state", state)` en cada cambio. Sus ramas: | Rama | Qué guarda | |------|------------| | `engine` | motor activo: `projectm`, `butterchurn`, `hydra`, `shaders`, `mixer` | | `power` | visuales encendidas o en negro | | `sensitivity` | ganancia aplicada al audio antes del análisis | | `audio` | fuente (`mic` / `monitor`), tarjeta elegida y BPM detectado | | `butterchurn` | preset, cambio automático (segundos o compases) y transición | | `hydra` / `shaders` | el código que se está ejecutando y su etiqueta | | `mixer` | las dos capas (`source`, `fondo`, `camOn`, `video`), modo de mezcla y efectos | | `projectm` | categoría, visual, variante, favoritos, monitor, congelado | | `mapping` | `enabled`, `edit`, `masks[]` y `surfaces[]`, donde cada superficie es una **capa** (geometría + `fuente` + opacidad + mezcla) | | `meta` | listas detectadas: presets, cámaras, micrófonos, vídeos, monitores | | `status` / `notifications` / `network` | qué se ve, avisos e IP para el QR | Lo que sobrevive a un reinicio se guarda en `data/`: `mapping.json` (mapping) y `pm-favoritos.json` (favoritos de projectM). El resto del estado es de la sesión. ## Eventos de WebSocket **Del panel al servidor** | Evento | Qué hace | |--------|----------| | `set_power`, `set_engine`, `set_sensitivity` | los tres mandos básicos | | `update_settings {engine, patch}` | parchea una rama del estado (`butterchurn`, `mixer`, `audio`, `projectm`) | | `run_code {engine, code, label}` | ejecuta código Hydra o GLSL | | `engine_command {action}` | `next`, `prev`, `lock`, `fix_current` | | `set_mapping {…}` | activar, editar, superficies y máscaras (persiste) | | `pm_favorito {action…}` | añadir, borrar o lanzar un favorito de projectM | | `rescan_devices`, `rescan_videos`, `reacquire_audio` | volver a mirar el hardware y la galería | | `system {action}` | apagar o reiniciar la máquina | **Del escenario al servidor** | Evento | Qué hace | |--------|----------| | `stage_meta` | publica lo que ha detectado el navegador (micrófonos, cámaras, presets) | | `stage_status {label, bpm}` | qué se está viendo y a cuántos BPM | | `stage_notify {level, message}` | manda un aviso a la banda del panel | | `audio_route {target}` | pide enrutar la captura al monitor del sistema o al micro | | `set_mapping` | al arrastrar los tiradores sobre las propias visuales | **Del servidor a los clientes**: `state` (el estado entero), `status` (solo etiqueta y BPM, más ligero), `notify`, `stage_command`, `stage_rescan`, `stage_reacquire`. **Nada de esto pide credenciales por defecto.** Cualquiera que alcance el puerto puede emitir cualquier evento, y eso incluye apagar la máquina y ejecutar código en el escenario. Si se configura `auth.token`, el handler de `connect` rechaza a quien no lo traiga en `auth`, y con eso queda cerrado todo lo demás de golpe. Ver [Seguridad](seguridad.md). Lo que entra por el socket **no es de fiar**, así que se filtra antes de tocar el estado: `update_settings` pasa por una lista blanca de claves por motor (`CLAVES_AJUSTES`), y `set_mapping` sanea capas y máscaras y limita cuántas acepta. `mapping.json` se escribe **con un segundo de retardo**: arrastrar un punto emite ~16 veces por segundo y no queremos escribir la microSD a ese ritmo. ## Los motores Cada motor es independiente: si a uno le falta su librería o revienta al arrancar, **los demás siguen funcionando**. `boot()` en `stage.js` los inicia uno a uno dentro de su propio `try`. | Motor | Dónde corre | Lienzo | |-------|-------------|--------| | **projectM** | proceso nativo, ventana propia | ninguno (la página se queda en negro debajo) | | **Butterchurn** | navegador, WebGL | `#butterchurn` | | **Hydra** | navegador, WebGL | `#hydra` | | **Shaders** | navegador, WebGL a pelo | `#shaders` | | **Mezclador** | navegador, **sobre Hydra** | `#hydra` | `applyState()` decide qué lienzo se ve y apaga los demás. El mezclador no es un motor aparte: **genera código Hydra** (`buildMixerCode`) a partir de los ajustes; por eso comparte lienzo con Hydra y nunca coinciden. Butterchurn tiene una particularidad: puede estar pintando **sin ser el motor activo**, cuando hace de capa de fondo del mezclador. Esa condición está en un único sitio, `butterLive()`, y de ella dependen el preset, el cambio automático y los botones de siguiente/anterior. ### Añadir un motor nuevo 1. `ENGINES` en `server.py` y una rama en `state` con sus ajustes. 2. Un lienzo en `web/stage/index.html` y su rama en `applyState()`. 3. Un botón `.engine` en `web/panel/index.html` y su tarjeta de controles. 4. Mostrar u ocultar esa tarjeta en `render()` de `panel.js`. 5. Una entrada en `data/ayuda.json` para el botón de información. ## Audio y BPM Toda la cadena de audio está **en el navegador** (`stage.js`): `getUserMedia` → `GainNode` (la sensibilidad) → `AnalyserNode`. De ahí beben el detector de ritmo, Butterchurn, Hydra y los shaders. El detector (`BeatDetector`) mira la energía de graves, marca un pulso cuando supera su media reciente, agrupa los intervalos entre pulsos por parecido y se queda con el grupo mayoritario. El resultado se dobla o se divide hasta caer en 70–180 BPM. Se publica en `fosBeat` y llega a los motores como `u_bpm`/`u_beat` (shaders) o `bpm` (Hydra). La fuente **monitor** («audio del navegador») merece una nota: Chromium oculta los monitores de PulseAudio/PipeWire al enumerar dispositivos. El plan B es capturar la entrada por defecto y pedir al servidor que **mueva ese stream** al monitor de la salida con `pactl`. Solo funciona con servidor y escenario en la misma máquina — que es el caso del kiosko y del modo portátil. ## Mapping y capas Dos módulos, con una división limpia: **`layers.js` produce imágenes** y **`mapper.js` las coloca**. ``` layers.js mapper.js ───────── ───────── capa 1 → Butterchurn → canvas ┐ capa 2 → clip .mp4 → video ├→ resolver(surface) → textura → malla → #output capa 3 → ShaderEngine → canvas ┘ (WebGL) capa 4 → "motor" → el lienzo del motor activo ``` **`layers.js`** mantiene una instancia por capa y la pone al día con el estado (`sync`): crea las nuevas, cambia el preset de las que lo hayan cambiado y destruye las borradas. Decide también qué se comparte y qué no: - Con **estado propio** (Butterchurn, shaders): **una instancia por capa**, para que dos zonas puedan llevar presets distintos. - Sin estado (clips, cámara): **compartidas por contenido** con cuenta de usos, así el mismo clip en dos capas se reproduce una sola vez. Cada capa se renderiza en un lienzo de **640×360**: el mapper la estira al deformarla, y esa resolución es lo que hace viable tener varias a la vez en una Raspberry. **`mapper.js`** es el compositor WebGL. Para cada superficie pide su textura al `resolver` (que es `FosLayers.fuenteDe`), la sube **una sola vez por fotograma y por clave de fuente**, y la dibuja sobre la malla con una homografía por celda. Encima aplica opacidad (`uAlpha`) y modo de mezcla (`blendFunc`: normal o aditivo), y al final pinta las máscaras en negro. Las superficies se guardan en coordenadas **normalizadas 0..1**, así que un mapping hecho a 1080p sigue valiendo en otra resolución. El formato completo está documentado en la cabecera de `mapper.js`. Guía de uso: [Projection mapping](mapping.md). Una capa **no puede ser projectM**: es un proceso nativo con su propia ventana, fuera del alcance de un contexto WebGL del navegador. Su equivalente es la fuente `butter`, que son los mismos presets de MilkDrop. ## Estructura de archivos ``` FOSFENO/ ├── backend/server.py Estado, WebSocket, procesos, audio, subidas ├── web/panel/ Panel de control (móvil): index.html, panel.js, panel.css ├── web/stage/ Escenario: stage.js (motores + audio), layers.js, mapper.js ├── web/lib/ Librerías servidas tal cual (butterchurn, hydra, codemirror…) ├── data/ Contenido y ajustes que persisten (ver abajo) ├── scripts/ Arranque del kiosko, build de projectM, lib.sh ├── docs/ Esta documentación ├── install.sh Instalador multi-distro (Debian, Fedora, Arch, openSUSE) └── fosfeno Lanzador del modo portátil ``` `data/` mezcla dos cosas: **contenido editable** que sí va al repositorio (`hydra-sketches.json`, `hydra-snippets.json`, `shaders.json`, `ayuda.json`, `presets-projectm/`) y **estado de cada instalación** que no va (`mapping.json`, `pm-favoritos.json`, `videos/`) — ver `.gitignore`. ## Criterios que sigue el código Vale la pena respetarlos al tocar algo: - **Nunca callar un fallo.** Todo error acaba en la banda de avisos del panel con una frase que dice qué hacer, no un volcado técnico. Para eso está `report()` en el escenario y `notify()` en el servidor. - **Que un fallo no se lleve el resto por delante.** Sin cámara, sin micro o sin projectM, lo demás sigue proyectando. - **El estado manda.** Si algo hay que recordar entre clientes, va al estado; no se guardan verdades locales en el panel. - **Textos en castellano y sin jerga.** Tanto la interfaz como los avisos están escritos para alguien que no ha leído el código. - **Nada de recursos huérfanos.** Cada instancia de motor lleva su `destruir()` y suelta lo suyo: `requestAnimationFrame`, temporizadores, `MediaStream` (el piloto de la cámara) y **el contexto WebGL** (`soltarGL`). El navegador solo aguanta ~16 contextos y al pasarse mata el más viejo, que es el del compositor de mapping: dejarlos colgando acaba en proyector negro. - **Cambiar un ajuste no recrea el motor.** En `layers.js`, la firma de una fuente decide si la instancia se reaprovecha; los ajustes (preset, cambio automático) se aplican en caliente con `aplicar()`. ### Trampa conocida: `hydra.hush()` vacía las fuentes Al salir del Mezclador hacia otro motor se llama a `hydra.hush()`, y eso **borra `s0` y `s1`**. Por eso el escenario recuerda en `mixerS1` qué elemento tenía enganchado y lo vuelve a enlazar (`reengancharMixer`) al volver: sin eso, ir a Butter y volver dejaba el mezclador sin la capa de encima. Si algún día se añade otra fuente de Hydra, hay que recordarla igual.