FOSFENO: motor de visuales audio-reactivas para Raspberry Pi

Primera version. Cinco motores (projectM, Butterchurn, Hydra, Shaders GLSL y mezclador VJ con camara y video), panel de control web, deteccion de BPM propia, pantalla de conexion con codigo QR, instalador robusto para Raspberry Pi 4 y 5 y documentacion completa en docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-05-22 14:18:19 +02:00
commit 30a09fdee6
31 changed files with 3478 additions and 0 deletions

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Entorno Python
.venv/
__pycache__/
*.pyc
# Librerias web (las regenera install.sh)
web/node_modules/
web/lib/
web/package-lock.json
# Presets de projectM (los descarga install.sh)
data/presets-projectm/
# Videos del mezclador (los copia el usuario), pero conservamos el README
data/videos/*
!data/videos/README.txt
# Temporales
*.log
*.tmp

170
README.md Normal file
View file

@ -0,0 +1,170 @@
![FOSFENO](docs/assets/banner.svg)
# FOSFENO
**Motor de visuales audio-reactivas para Raspberry Pi.**
Convierte una Raspberry Pi + un proyector + un micro USB en una estación de
VJ automática: la Pi escucha la música de la sala, detecta su BPM y proyecta
visuales que reaccionan al sonido. Todo se controla desde un panel web.
```
[ Música en la sala ]
│ (micro USB)
┌──────────────────┐ Wi-Fi / Ethernet
│ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.71/
│ FOSFENO │ (panel en el móvil)
└────────┬─────────┘
│ micro-HDMI
[ Proyector :: visuales ]
```
## Documentación
La carpeta [`docs/`](docs/) tiene la guía completa:
- [Requisitos y hardware](docs/requisitos.md) — qué Raspberry, qué micrófono
USB y qué cámara usar para que se reconozcan solos.
- [Instalación](docs/instalacion.md) — qué descarga y compila el instalador.
- [Conectarse al panel](docs/conexion.md) — el código QR, `fosfeno.local` y
el router.
- [Uso del panel](docs/uso.md) — cómo se maneja y qué hace cada motor.
- [Solución de problemas](docs/problemas.md) — qué hacer cuando algo falla.
El panel además lleva un botón de información en cada apartado: explica qué
es, qué necesita y cómo se configura, sin salir del propio panel.
## Motores de visuales
Los cinco se eligen y configuran desde el panel, en caliente:
| Motor | Qué es |
|----------------|------------------------------------------------------------|
| **projectM** | Visualizador MilkDrop nativo, compilado en la Pi |
| **Butterchurn**| MilkDrop en WebGL, miles de presets |
| **Hydra** | Código Hydra en vivo: editor + librería de fragmentos |
| **Shaders** | Shaders GLSL audio-reactivos (estilo Shadertoy), con editor|
| **Mezclador** | VJ tipo Resolume: cámara + clips de vídeo + efectos |
## Hardware necesario
- Raspberry Pi 5 o Pi 4 + fuente oficial + microSD/SSD
- Cable **micro-HDMI → HDMI** para el proyector
- **Micrófono USB** (la Pi no tiene entrada de audio propia)
- **Webcam USB** (opcional, para el modo Mezclador)
- Disipador o ventilador (las visuales tiran de GPU)
- Red por Ethernet o Wi-Fi
## Instalación
En Raspberry Pi OS Bookworm (64 bits, con escritorio):
```bash
git clone <URL-de-tu-gitlab>/fosfeno.git
cd fosfeno
bash install.sh
```
El instalador es robusto: detecta si es **Pi 4 o Pi 5**, comprueba el sistema
operativo, **verifica las versiones** de cada herramienta (Python, Node, npm,
CMake) y avisa con `[ OK ]` / `[ !! ]` / `[ XX ]` de cada paso.
```
bash install.sh # instala todo (incluido projectM)
bash install.sh --no-projectm # omite la compilación de projectM
bash install.sh --check # solo comprueba el sistema, no instala nada
```
Después:
```bash
sudo raspi-config # System Options → Boot/Auto Login → Desktop Autologin
sudo reboot
```
Al reiniciar, la Pi arranca sola en modo kiosko mostrando las visuales.
## Uso
- **Visuales** → salen automáticamente por el proyector (HDMI).
- **Panel de control** → al arrancar, el proyector muestra un **código QR** y
la dirección. Escanéalo con el móvil y el panel se abre. También se llega
escribiendo `http://fosfeno.local/`. El móvil debe estar en la misma red.
Ver [Conectarse al panel](docs/conexion.md).
Desde el panel puedes:
- Encender/apagar las visuales y cambiar de motor.
- Elegir la **tarjeta de audio** y ver el **BPM detectado** en vivo.
- Ajustar la sensibilidad al audio.
- **Butterchurn**: presets, transición, cambio automático por segundos o
**sincronizado al compás**.
- **Hydra / Shaders**: editor de código integrado para **escribir o pegar**
tu propio código, más una **librería de fragmentos** lista para cargar.
- **Mezclador VJ**: activar la cámara, elegir un clip de vídeo, modo de
mezcla y efectos de color (tono, saturación, colorama, posterizado,
pixelado, caleidoscopio, feedback, invertir…).
### Modo Mezclador (cámara + vídeo)
Copia tus clips en `data/videos/` (formatos `.mp4`, `.webm`, `.mov`…) y
aparecerán en el panel. El mezclador genera código Hydra por debajo a partir
de los controles, así que mezcla en tiempo real la webcam, los clips y los
efectos de color — el equivalente a Resolume, pero corriendo en la propia Pi.
## Estructura
```
FOSFENO/
├── install.sh / uninstall.sh Instalador robusto y desinstalador
├── config.json Configuración (puerto, micro, valores por defecto)
├── backend/server.py Servidor: web + WebSocket + gestión de procesos
├── scripts/lib.sh Funciones de los scripts (logs, versiones)
├── web/panel/ Panel de control (móvil)
├── web/stage/ Escenario en Chromium (todos los motores web)
├── docs/ Documentación completa
├── data/hydra-sketches.json Sketches de Hydra de fábrica
├── data/hydra-snippets.json Librería de fragmentos de Hydra para el editor
├── data/shaders.json Shaders GLSL (editables)
├── data/ayuda.json Textos de ayuda que muestra el panel
└── data/videos/ Tus clips de vídeo para el Mezclador
```
Cuando algo falla (un error de código, una cámara que no responde, projectM
sin instalar), FOSFENO no se queda callado: el aviso aparece en una banda en
la parte de arriba del panel, con el color según su gravedad.
## Detección de BPM
FOSFENO incluye un detector de ritmo propio (análisis de energía de graves
en tiempo real) que estima el BPM de la música ambiente. El BPM se muestra en
el panel y alimenta a todos los motores:
- **Shaders**: uniforms `u_bpm` y `u_beat` (fase 0..1 sincronizada al pulso).
- **Hydra**: actualiza la variable global `bpm` (la usan `.fast()`, etc.).
- **Butterchurn**: cambio de preset cada N compases.
## Configuración (`config.json`)
- `server.port` — puerto del panel. `80` permite `http://IP/` sin puerto;
si da problemas de permisos, cámbialo a `8080`.
- `audio.matchSource` — subcadena para localizar el micro USB (por defecto `usb`).
- `defaults` — motor, sensibilidad, sketch de Hydra y shader al arrancar.
## Notas
- **Pi 5 usa Wayland.** El cambio manual de preset en projectM solo funciona
en sesión X11; en Wayland projectM rota presets automáticamente. El resto
de motores no se ven afectados.
- Para el Mezclador, usa clips de vídeo ligeros (720p o menos, H.264).
- Los fragmentos de Hydra de `data/hydra-snippets.json` están adaptados de
ejemplos de la comunidad de Hydra
([hydra-synth/hydra](https://github.com/hydra-synth/hydra),
[zachkrall/hydra-examples](https://github.com/zachkrall/hydra-examples)).
- Uniforms de los shaders GLSL: `u_resolution`, `u_time`, `u_bass`, `u_mid`,
`u_treble`, `u_level`, `u_bpm`, `u_beat`, `u_fft`.
---
*Parte de COFRE/CODERS — creative coding audio-reactivo.*

3
backend/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask>=3.0
flask-socketio>=5.3
simple-websocket>=1.0

488
backend/server.py Normal file
View file

@ -0,0 +1,488 @@
#!/usr/bin/env python3
"""
FOSFENO - Motor de visuales audio-reactivas para Raspberry Pi OS.
Un unico proceso que:
- sirve el PANEL de control (http://<ip>/)
- sirve el ESCENARIO (http://<ip>/stage) -> lo abre Chromium en kiosko
- lanza/cierra Chromium y el visualizador nativo projectM
- mantiene el estado y lo sincroniza con panel y escenario via WebSocket
Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
(mezclador VJ basado en Hydra: camara + video + efectos).
"""
import json
import shutil
import socket
import subprocess
import threading
import time
from pathlib import Path
from flask import Flask, send_from_directory
from flask_socketio import SocketIO
# --------------------------------------------------------------------------
# Rutas y configuracion
# --------------------------------------------------------------------------
BASE = Path(__file__).resolve().parent.parent # .../FOSFENO
WEB = BASE / "web"
DATA = BASE / "data"
VIDEOS = DATA / "videos"
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
def load_json(path, default):
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"[FOSFENO] No se pudo leer {path}: {exc}")
return default
CFG = load_json(BASE / "config.json", {})
HOST = CFG.get("server", {}).get("host", "0.0.0.0")
PORT = int(CFG.get("server", {}).get("port", 80))
app = Flask(__name__, static_folder=None)
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
# --------------------------------------------------------------------------
# Datos cargados de disco
# --------------------------------------------------------------------------
HYDRA_SKETCHES = load_json(DATA / "hydra-sketches.json", {})
SHADERS = load_json(DATA / "shaders.json", {})
DEFAULTS = CFG.get("defaults", {})
ENGINES = ("projectm", "butterchurn", "hydra", "shaders", "mixer")
def first_code(table, key, field="code"):
"""Devuelve el codigo de una entrada de un diccionario de presets."""
entry = table.get(key) or next(iter(table.values()), {})
return entry.get(field, "") if isinstance(entry, dict) else ""
def list_videos():
if not VIDEOS.is_dir():
return []
return sorted(f.name for f in VIDEOS.iterdir()
if f.suffix.lower() in VIDEO_EXT)
# --------------------------------------------------------------------------
# Estado global (se difunde a todos los clientes)
# --------------------------------------------------------------------------
NOTIFY_HISTORY = [] # ultimos avisos mostrados en el panel
state = {
"engine": DEFAULTS.get("engine", "butterchurn"),
"power": True,
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
"audio": {"device": "", "bpm": 0},
"butterchurn": {
"preset": "", "shuffle": True,
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
},
"hydra": {
"label": "",
"code": first_code(HYDRA_SKETCHES, DEFAULTS.get("hydraSketch", "")),
},
"shaders": {
"label": "",
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
},
"mixer": {
"source": "cam", "camOn": True, "cameraId": 0, "video": "",
"mix": 0.5, "blendMode": "blend",
"hue": 0.0, "saturate": 1.0, "contrast": 1.0, "brightness": 0.0,
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
"rotate": 0.0, "feedback": 0.0, "invert": False, "beatPulse": False,
},
"projectm": {"available": False},
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
"cameraDevices": [],
"videos": list_videos(),
},
"status": {"label": ""},
"notifications": NOTIFY_HISTORY,
"network": {"ip": "", "hostname": "", "port": PORT, "panelSeen": False},
}
lock = threading.Lock()
def broadcast():
socketio.emit("state", state)
def notify(level, message):
"""Registra un aviso y lo envia al panel.
level puede ser 'info', 'warn' o 'error'. Asi el usuario nunca se queda
sin saber que ha pasado: cualquier fallo aparece en la parte de arriba
del panel de control.
"""
entry = {"level": level, "message": str(message), "t": int(time.time())}
print(f"[FOSFENO] aviso({level}): {message}")
NOTIFY_HISTORY.append(entry)
while len(NOTIFY_HISTORY) > 20:
NOTIFY_HISTORY.pop(0)
try:
socketio.emit("notify", entry)
except Exception:
pass
def get_lan_ip():
"""Devuelve la IP de la Raspberry en la red local."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return sock.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
sock.close()
def refresh_network():
"""Actualiza la IP y el nombre de red de la Raspberry. Devuelve True si la
IP ha cambiado, para avisar al panel y al escenario."""
new_ip = get_lan_ip()
changed = state["network"]["ip"] != new_ip
state["network"]["ip"] = new_ip
state["network"]["hostname"] = socket.gethostname()
return changed
# --------------------------------------------------------------------------
# Gestion de procesos (Chromium kiosko y projectM nativo)
# --------------------------------------------------------------------------
procs = {"chromium": None, "projectm": None}
def is_running(proc):
return proc is not None and proc.poll() is None
def start_chromium():
"""Abre el escenario en Chromium a pantalla completa."""
if is_running(procs["chromium"]):
return
kiosk = CFG.get("kiosk", {})
browser = shutil.which(kiosk.get("browser", "chromium-browser")) \
or shutil.which("chromium")
if not browser:
notify("error", "No se encontro Chromium. Las visuales web no pueden "
"mostrarse. Ejecuta install.sh para instalarlo.")
return
url = kiosk.get("url", f"http://localhost:{PORT}/stage")
procs["chromium"] = subprocess.Popen([
browser,
"--kiosk", f"--app={url}",
"--use-fake-ui-for-media-stream", # acepta micro y camara sin preguntar
"--autoplay-policy=no-user-gesture-required",
"--noerrdialogs", "--disable-infobars", "--disable-translate",
"--disable-features=Translate",
"--check-for-update-interval=31536000",
"--ozone-platform-hint=auto",
])
print("[FOSFENO] Chromium abierto en", url)
def stop_projectm():
proc = procs["projectm"]
if is_running(proc):
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
procs["projectm"] = None
def start_projectm():
"""Lanza projectM nativo; su ventana se superpone a Chromium."""
stop_projectm()
script = BASE / "scripts" / "start-projectm.sh"
try:
procs["projectm"] = subprocess.Popen(["bash", str(script)])
print("[FOSFENO] projectM lanzado.")
except OSError as exc:
notify("error", f"No se pudo lanzar projectM: {exc}. "
"Prueba con otro motor de visuales.")
def projectm_key(key):
"""Envia una tecla a projectM (solo funciona en sesion X11, no Wayland)."""
if not shutil.which("xdotool"):
return
subprocess.run(
["xdotool", "search", "--name", "projectM",
"key", "--clearmodifiers", key],
timeout=3, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def apply_engine():
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
use_native = state["engine"] == "projectm" and state["power"]
if state["engine"] == "projectm" and not state["projectm"]["available"]:
notify("warn", "projectM no esta instalado en esta Raspberry. "
"Elige otro motor o vuelve a compilarlo con "
"scripts/build-projectm.sh.")
if use_native and state["projectm"]["available"]:
start_projectm()
else:
stop_projectm()
# --------------------------------------------------------------------------
# Rutas HTTP (panel, escenario, librerias, datos)
# --------------------------------------------------------------------------
@app.route("/")
def panel_index():
return send_from_directory(str(WEB / "panel"), "index.html")
@app.route("/panel/<path:fname>")
def panel_files(fname):
return send_from_directory(str(WEB / "panel"), fname)
@app.route("/stage")
def stage_index():
return send_from_directory(str(WEB / "stage"), "index.html")
@app.route("/stage/<path:fname>")
def stage_files(fname):
return send_from_directory(str(WEB / "stage"), fname)
@app.route("/lib/<path:fname>")
def lib_files(fname):
return send_from_directory(str(WEB / "lib"), fname)
@app.route("/data/<path:fname>")
def data_files(fname):
return send_from_directory(str(DATA), fname)
# --------------------------------------------------------------------------
# Eventos WebSocket
# --------------------------------------------------------------------------
@socketio.on("connect")
def on_connect():
socketio.emit("state", state)
@socketio.on("hello")
def on_hello(data):
"""El panel se presenta al conectarse. Asi el escenario sabe que alguien
ya ha encontrado el panel y puede dejar de mostrar el codigo QR."""
if (data or {}).get("role") == "panel" and not state["network"]["panelSeen"]:
with lock:
state["network"]["panelSeen"] = True
broadcast()
@socketio.on("set_engine")
def on_set_engine(data):
engine = (data or {}).get("engine")
if engine not in ENGINES:
return
with lock:
state["engine"] = engine
apply_engine()
broadcast()
@socketio.on("set_power")
def on_set_power(data):
with lock:
state["power"] = bool((data or {}).get("on"))
apply_engine()
broadcast()
@socketio.on("set_sensitivity")
def on_set_sensitivity(data):
try:
value = float((data or {}).get("value", 1.0))
except (TypeError, ValueError):
return
with lock:
state["sensitivity"] = max(0.0, min(4.0, value))
broadcast()
@socketio.on("run_code")
def on_run_code(data):
"""Ejecuta codigo (Hydra o GLSL) escrito/pegado o un fragmento de la libreria."""
engine = (data or {}).get("engine")
code = (data or {}).get("code")
label = (data or {}).get("label", "codigo personalizado")
if engine in ("hydra", "shaders") and isinstance(code, str):
with lock:
state[engine]["code"] = code
state[engine]["label"] = label
state["status"]["label"] = label
broadcast()
@socketio.on("update_settings")
def on_update_settings(data):
"""Actualiza ajustes de un motor: butterchurn, mixer o audio."""
engine = (data or {}).get("engine")
patch = (data or {}).get("patch", {})
if engine in ("butterchurn", "mixer", "audio") and isinstance(patch, dict):
with lock:
state[engine].update(patch)
broadcast()
@socketio.on("engine_command")
def on_engine_command(data):
action = (data or {}).get("action")
if state["engine"] == "projectm":
projectm_key({"next": "n", "prev": "p"}.get(action, "n"))
else:
socketio.emit("stage_command", {"action": action,
"value": (data or {}).get("value")})
@socketio.on("stage_meta")
def on_stage_meta(data):
"""El escenario nos envia las listas que solo conoce el navegador."""
data = data or {}
with lock:
if isinstance(data.get("butterchurnPresets"), list):
state["meta"]["butterchurnPresets"] = data["butterchurnPresets"]
if isinstance(data.get("audioDevices"), list):
state["meta"]["audioDevices"] = data["audioDevices"]
if isinstance(data.get("cameraDevices"), list):
state["meta"]["cameraDevices"] = data["cameraDevices"]
broadcast()
@socketio.on("rescan_devices")
def on_rescan_devices():
"""Pide al escenario que vuelva a buscar microfonos y camaras."""
socketio.emit("stage_rescan")
@socketio.on("stage_status")
def on_stage_status(data):
if isinstance(data, dict):
with lock:
if "label" in data:
state["status"]["label"] = data["label"]
if "bpm" in data:
state["audio"]["bpm"] = data["bpm"]
socketio.emit("status", {"label": state["status"]["label"],
"bpm": state["audio"]["bpm"]})
@socketio.on("stage_notify")
def on_stage_notify(data):
"""El escenario (navegador) nos comunica un error para mostrarlo en el panel."""
data = data or {}
notify(data.get("level", "error"),
data.get("message", "Error en el escenario de visuales."))
@socketio.on("rescan_videos")
def on_rescan_videos():
with lock:
state["meta"]["videos"] = list_videos()
broadcast()
@socketio.on("system")
def on_system(data):
action = (data or {}).get("action")
if action == "reboot":
subprocess.Popen(["sudo", "reboot"])
elif action == "shutdown":
subprocess.Popen(["sudo", "poweroff"])
# --------------------------------------------------------------------------
# Arranque
# --------------------------------------------------------------------------
def setup_audio():
"""Marca el microfono USB como fuente de audio por defecto (best-effort)."""
match = CFG.get("audio", {}).get("matchSource", "usb").lower()
try:
out = subprocess.check_output(["pactl", "list", "short", "sources"],
text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return
for line in out.splitlines():
cols = line.split("\t")
if len(cols) >= 2 and match in cols[1].lower() \
and "monitor" not in cols[1].lower():
subprocess.run(["pactl", "set-default-source", cols[1]], check=False)
print("[FOSFENO] Microfono por defecto:", cols[1])
return
notify("warn", "No se detecto ningun microfono USB. Conecta uno y "
"reinicia, o elige la entrada a mano en el panel.")
def watchdog():
"""Reabre Chromium si se cierra (salvo cuando projectM esta activo)."""
while True:
time.sleep(10)
if refresh_network():
broadcast()
if state["engine"] != "projectm" and not is_running(procs["chromium"]):
notify("warn", "El navegador de las visuales se cerro. "
"Reabriendolo automaticamente.")
start_chromium()
def check_install():
"""Avisa al panel si la instalacion esta incompleta."""
needed = ["socket.io.min.js", "butterchurn.min.js",
"butterchurn-presets.min.js", "hydra-synth.js"]
missing = [f for f in needed if not (WEB / "lib" / f).exists()]
if missing:
notify("error", "Faltan librerias web (" + ", ".join(missing) +
"). Vuelve a ejecutar install.sh.")
if not (WEB / "lib" / "codemirror" / "codemirror.js").exists():
notify("warn", "Falta CodeMirror: el editor de codigo no funcionara. "
"Vuelve a ejecutar install.sh.")
def main():
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
state["projectm"]["available"] = shutil.which(binary) is not None
print(f"[FOSFENO] projectM nativo: "
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
check_install()
setup_audio()
refresh_network()
threading.Timer(3.0, start_chromium).start()
threading.Thread(target=watchdog, daemon=True).start()
net = state["network"]
suffix = "" if PORT == 80 else f":{PORT}"
print("[FOSFENO] ===================================================")
print("[FOSFENO] Panel de control disponible en:")
print(f"[FOSFENO] http://{net['ip']}{suffix}/")
print(f"[FOSFENO] http://{net['hostname']}.local{suffix}/")
print("[FOSFENO] ===================================================")
socketio.run(app, host=HOST, port=PORT, allow_unsafe_werkzeug=True)
if __name__ == "__main__":
main()

27
config.json Normal file
View file

@ -0,0 +1,27 @@
{
"server": {
"host": "0.0.0.0",
"port": 80
},
"kiosk": {
"url": "http://localhost/stage",
"browser": "chromium-browser"
},
"audio": {
"matchSource": "usb",
"_comment": "Subcadena para identificar el micro USB entre las fuentes de audio del sistema"
},
"projectm": {
"binary": "projectMSDL"
},
"network": {
"hostname": "fosfeno",
"_comment": "Nombre de red de la Raspberry. El panel queda en http://<hostname>.local/"
},
"defaults": {
"engine": "butterchurn",
"sensitivity": 1.0,
"hydraSketch": "kaleido",
"shader": "tunel"
}
}

86
data/ayuda.json Normal file
View file

@ -0,0 +1,86 @@
{
"_info": "Textos de ayuda que muestra el panel al pulsar los botones de informacion.",
"ayuda": {
"power": {
"title": "Encendido de las visuales",
"body": [
"Pone en marcha o detiene las visuales que salen por el proyector.",
"Apagar deja la pantalla en negro pero NO apaga la Raspberry. Es util para hacer una pausa sin tener que reiniciar nada.",
"Para apagar la Raspberry de verdad, usa los botones de Reiniciar y Apagar del final del panel."
]
},
"engines": {
"title": "Motor de visuales",
"body": [
"FOSFENO tiene cinco motores de visuales distintos. Solo uno funciona a la vez.",
"Cada boton redondo cambia al motor correspondiente. Al cambiar de motor aparecen debajo sus propios controles.",
"Pulsa el boton de informacion de cada motor para saber que hace, que necesita y como se configura."
]
},
"projectm": {
"title": "Motor projectM",
"body": [
"Que es: el visualizador clasico de MilkDrop, compilado dentro de la Raspberry. Reacciona al audio por si solo y va rotando entre miles de presets.",
"Requisitos: se compila durante la instalacion. Si la compilacion fallo, este motor sale como no disponible y hay que volver a compilarlo. Los otros cuatro motores funcionan sin el.",
"Como se configura: projectM es un programa nativo, su ventana se pone por encima de las demas visuales. Los botones Anterior y Siguiente cambian de preset, pero eso solo funciona en sesion X11; en Wayland projectM rota presets automaticamente."
]
},
"butterchurn": {
"title": "Motor Butterchurn",
"body": [
"Que es: MilkDrop reescrito para el navegador, con los mismos miles de presets. Reacciona al audio.",
"Requisitos: ninguno especial, se descarga durante la instalacion.",
"Como se configura: puedes elegir un preset concreto en la lista, o activar el cambio automatico. El cambio automatico puede ir por segundos o sincronizado al compas de la musica. El control de transicion ajusta cuanto dura el fundido entre un preset y el siguiente."
]
},
"hydra": {
"title": "Motor Hydra",
"body": [
"Que es: visuales generadas por codigo. Hydra es un lenguaje para escribir visuales en vivo.",
"Requisitos: ninguno especial, se descarga durante la instalacion.",
"Como se configura: el editor de codigo te deja escribir o pegar codigo de Hydra y ejecutarlo al momento. La libreria trae fragmentos listos para usar. En el codigo tienes disponibles time, los valores de audio a.fft[0] a a.fft[4] y la variable bpm."
]
},
"shaders": {
"title": "Motor Shaders",
"body": [
"Que es: shaders GLSL, el tipo de visual de Shadertoy. Programas que dibujan cada pixel de la pantalla.",
"Requisitos: ninguno especial. Los shaders pesados cargan la GPU; si van a tirones, usa un disipador o cambia a un motor mas ligero.",
"Como se configura: el editor de codigo te deja escribir o pegar shaders GLSL. Tienes los uniforms u_time, u_bass, u_mid, u_treble, u_level, u_bpm, u_beat y la textura u_fft con el espectro de audio."
]
},
"mixer": {
"title": "Motor Mezclador VJ",
"body": [
"Que es: el modo de video. Mezcla la imagen de una camara web con clips de video y efectos de color. Es lo mas parecido a un programa de VJ como Resolume.",
"Requisitos: una webcam USB que cumpla el estandar UVC, conectada antes de encender la Raspberry. Para los clips, copia tus archivos de video en la carpeta data/videos del proyecto.",
"Como se configura: elige la fuente (camara, video o mezcla de las dos), el modo de mezcla y los efectos de color con los controles deslizantes. La casilla de pulso al ritmo hace que la imagen lata con los graves.",
"Si tienes mas de una camara, eligela en la lista de camaras del panel. El boton Actualizar lista de videos vuelve a leer la carpeta data/videos por si has copiado clips nuevos."
]
},
"audio": {
"title": "Audio y BPM",
"body": [
"Que es: aqui se elige por que entrada escucha FOSFENO la musica, y se ve el BPM que detecta.",
"Requisitos: un microfono USB o una tarjeta de sonido USB con entrada. La Raspberry no tiene entrada de audio propia.",
"Como se configura: por defecto FOSFENO coge el microfono USB automaticamente. Si tienes varias entradas, eligela en la lista. El BPM se calcula solo a partir del sonido y tarda unos segundos en estabilizarse.",
"Si conectas el microfono o la camara con la Raspberry ya encendida, pulsa el boton Buscar dispositivos de nuevo y apareceran en sus listas sin tener que reiniciar."
]
},
"sensibilidad": {
"title": "Sensibilidad al audio",
"body": [
"Que es: ajusta cuanto reaccionan las visuales al volumen de la musica.",
"Como se configura: si la sala suena floja y las visuales se quedan quietas, sube la sensibilidad. Si todo se ve saturado y exagerado, bajala. El valor 1 es el punto de partida normal."
]
},
"editor": {
"title": "Editor de codigo",
"body": [
"Que es: un editor para escribir, pegar y ejecutar codigo de visuales en vivo. Aparece en los motores Hydra y Shaders.",
"Como se configura: elige un ejemplo de la libreria y se carga en el editor, listo para ejecutarse. Puedes modificarlo o pegar codigo tuyo. El boton Ejecutar lanza lo que haya en el editor. El boton Limpiar lo vacia.",
"Si el codigo tiene un error, FOSFENO lo avisa en la parte de arriba del panel y el detalle tecnico queda en la consola del navegador."
]
}
}
}

22
data/hydra-sketches.json Normal file
View file

@ -0,0 +1,22 @@
{
"kaleido": {
"name": "Caleidoscopio",
"code": "osc(10, 0.1, () => 1 + a.fft[0]*2).color(0.9,0.2,0.8).rotate(() => time*0.1).kaleid(() => 3 + Math.round(a.fft[2]*6)).modulate(noise(() => 1 + a.fft[1]*3), 0.3).out(o0)"
},
"tunel": {
"name": "Tunel",
"code": "shape(99, 0.15, 0.5).repeat(() => 2 + a.fft[0]*5, () => 2 + a.fft[1]*5).modulateScale(osc(4), () => a.fft[2]).scale(() => 1 + a.fft[0]).color(0.2,0.8,1.0).out(o0)"
},
"plasma": {
"name": "Plasma",
"code": "osc(6, 0, () => a.fft[0]*4).modulate(osc(6).rotate(1.2), 0.4).color(() => 0.5+a.fft[1], 0.3, () => 0.8+a.fft[2]).saturate(2).out(o0)"
},
"celdas": {
"name": "Celdas",
"code": "voronoi(() => 4 + a.fft[0]*22, 0.3, 0.2).color(1.0,0.3,0.6).modulatePixelate(noise(3), 0.2).rotate(() => time*0.05).out(o0)"
},
"estrellas": {
"name": "Estrellas",
"code": "noise(() => 3 + a.fft[1]*6, 0.15).thresh(() => 0.62 - a.fft[0]*0.45).color(0.7,0.9,1.0).modulateRotate(osc(2), 0.2).out(o0)"
}
}

55
data/hydra-snippets.json Normal file
View file

@ -0,0 +1,55 @@
{
"_info": "Libreria de fragmentos de codigo Hydra para el editor de FOSFENO. Adaptados de ejemplos de la comunidad de Hydra (github.com/hydra-synth/hydra y github.com/zachkrall/hydra-examples). Edita o anade los tuyos libremente.",
"snippets": {
"osc-basico": {
"name": "Oscilador basico",
"author": "Hydra (comunidad)",
"code": "osc(20, 0.1, 0.8)\n .out(o0)"
},
"kaleido-audio": {
"name": "Caleidoscopio reactivo",
"author": "Hydra (comunidad)",
"code": "osc(15, 0.1, 1)\n .kaleid(() => 3 + a.fft[0]*8)\n .color(1, 0.4, 0.8)\n .rotate(() => time*0.1)\n .out(o0)"
},
"feedback": {
"name": "Feedback infinito",
"author": "Hydra (comunidad)",
"code": "osc(4, 0.1, 1.2)\n .modulate(o0, () => 0.4 + a.fft[0]*0.5)\n .color(0.9, 0.3, 0.6)\n .out(o0)"
},
"voronoi-liquido": {
"name": "Voronoi liquido",
"author": "Hydra (comunidad)",
"code": "voronoi(8, 0.3, 0.2)\n .modulate(osc(5).rotate(0.7), 0.4)\n .color(0.2, 0.8, 1.0)\n .out(o0)"
},
"lluvia-pixel": {
"name": "Lluvia de pixeles",
"author": "Hydra (comunidad)",
"code": "noise(() => 4 + a.fft[1]*8, 0.1)\n .thresh(0.7)\n .modulate(noise(2).scrollY(0, 0.2))\n .color(0.6, 0.9, 1.0)\n .out(o0)"
},
"tunel": {
"name": "Tunel pulsante",
"author": "Hydra (comunidad)",
"code": "shape(99, 0.0001, 0.5)\n .repeat(() => 3 + a.fft[0]*3, () => 3 + a.fft[0]*3)\n .modulateScale(osc(4), () => 0.2 + a.fft[2])\n .color(1.0, 0.3, 0.7)\n .out(o0)"
},
"osc-modulado": {
"name": "Oscilador modulado",
"author": "Hydra (comunidad)",
"code": "osc(40, 0.1, () => a.fft[0]*3)\n .modulate(osc(10).rotate(() => time*0.2), 0.5)\n .saturate(1.6)\n .out(o0)"
},
"triangulos": {
"name": "Triangulos al ritmo",
"author": "Hydra (comunidad)",
"code": "shape(3, () => 0.2 + a.fft[0]*0.4, 0.1)\n .repeat(5, 5)\n .rotate(() => time*0.1)\n .color(0.1, 0.9, 0.8)\n .out(o0)"
},
"espiral": {
"name": "Espiral cromatica",
"author": "Hydra (comunidad)",
"code": "osc(10, 0.05, 1)\n .kaleid(() => 2 + a.fft[1]*10)\n .scale(() => 1 + a.fft[0])\n .rotate(() => time*0.3)\n .colorama(() => a.fft[2]*0.5)\n .out(o0)"
},
"glitch-rgb": {
"name": "Glitch RGB",
"author": "Hydra (comunidad)",
"code": "osc(30, 0, 1)\n .modulate(noise(() => 2 + a.fft[0]*6), 0.3)\n .color(2.0, 0.5, 0.5)\n .modulateRotate(osc(2), 0.1)\n .out(o0)"
}
}
}

18
data/shaders.json Normal file
View file

@ -0,0 +1,18 @@
{
"plasma": {
"name": "Plasma neon",
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n float t = u_time*0.4;\n float v = sin(uv.x*4.0 + t) + sin(uv.y*4.0 - t)\n + sin((uv.x+uv.y)*4.0 + t)\n + sin(length(uv)*8.0 - t*2.0 - u_bass*6.0);\n v += u_treble*4.0;\n vec3 col = 0.5 + 0.5*cos(vec3(0.0,2.0,4.0) + v + u_mid*3.0);\n col *= 0.6 + u_level*1.4;\n gl_FragColor = vec4(col,1.0);\n}"
},
"tunel": {
"name": "Tunel infinito",
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n float a = atan(uv.y,uv.x);\n float r = length(uv);\n float d = 0.3/r + u_time*0.6 + u_bass*1.8;\n float rings = sin(d*10.0)*0.5+0.5;\n float spokes = sin(a*8.0 + u_time)*0.5+0.5;\n vec3 col = mix(vec3(0.04,0.0,0.18), vec3(0.0,1.0,0.9), rings*spokes);\n col += vec3(1.0,0.2,0.6)*u_treble*spokes*2.0;\n col *= smoothstep(0.0,0.45,r);\n gl_FragColor = vec4(col,1.0);\n}"
},
"rejilla": {
"name": "Rejilla cyber",
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n uv *= 1.0 + u_bass*0.9;\n uv.y += u_time*0.2;\n vec2 g = abs(fract(uv*4.0) - 0.5);\n float line = smoothstep(0.46,0.5, max(g.x,g.y));\n vec3 base = vec3(0.0,0.9,1.0) + vec3(u_mid*1.5);\n vec3 col = mix(vec3(0.02,0.0,0.06), base, 1.0-line);\n col += vec3(1.0,0.1,0.8)*u_treble*(1.0-line)*2.0;\n gl_FragColor = vec4(col,1.0);\n}"
},
"espectro": {
"name": "Espectro de barras",
"code": "void main(){\n vec2 uv = gl_FragCoord.xy/u_resolution;\n float f = texture2D(u_fft, vec2(uv.x, 0.5)).r;\n float bar = step(uv.y, f);\n float crest = smoothstep(0.03, 0.0, abs(uv.y - f));\n vec3 col = mix(vec3(0.4,0.0,0.8), vec3(0.0,1.0,0.7), uv.x) * bar;\n col += vec3(1.0,1.0,1.0) * crest;\n gl_FragColor = vec4(col, 1.0);\n}"
}
}

17
data/videos/README.txt Normal file
View file

@ -0,0 +1,17 @@
FOSFENO :: carpeta de videos
============================
Copia aqui tus clips de video para el modo "Mezclador VJ".
- Formatos: .mp4 (recomendado), .webm, .mov, .m4v, .ogv
- Apareceran automaticamente en el panel de control.
- Para mejor rendimiento en la Raspberry Pi usa clips:
* en 720p o menos
* codificados en H.264
* de corta duracion (loops)
Ejemplo desde linea de comandos para reescalar un video pesado:
ffmpeg -i original.mp4 -vf scale=-2:720 -c:v libx264 -an clip.mp4
Los videos NO se suben al repositorio (estan en .gitignore).

28
docs/README.md Normal file
View file

@ -0,0 +1,28 @@
# Documentación de FOSFENO
Esta carpeta reúne todo lo necesario para montar, instalar y usar FOSFENO sin
tener que adivinar nada. Está pensada para leerse en orden la primera vez y
para consultarse a saltos después.
- [Requisitos y hardware](requisitos.md)
Qué Raspberry Pi necesitas, qué cable va al proyector y, sobre todo, qué
micrófonos USB y qué cámaras funcionan sin instalar drivers.
- [Instalación](instalacion.md)
Cómo se instala paso a paso y, con detalle, qué descarga y compila el
script de instalación en tu Raspberry.
- [Conectarse al panel](conexion.md)
Cómo encuentra el usuario el panel de control: el código QR del proyector,
la dirección `fosfeno.local` y qué hacer con el router.
- [Uso del panel](uso.md)
Cómo se maneja desde el móvil, qué hace cada motor de visuales y cómo se
configura cada opción.
- [Solución de problemas](problemas.md)
Qué hacer cuando algo no arranca, no se ve o no suena. Incluye cómo leer
los mensajes de error que aparecen en el propio panel.
Si solo quieres empezar rápido, el archivo `README.md` de la raíz del
proyecto tiene la versión resumida.

16
docs/assets/README.md Normal file
View file

@ -0,0 +1,16 @@
# Imágenes del proyecto
Aquí va el material gráfico del repositorio.
- `banner.svg` — cabecera del README. Imagen original, vectorial.
Cuando tengas FOSFENO montado, lo ideal es añadir aquí **fotos reales** de tu
equipo y **capturas** de las visuales funcionando. Son más auténticas que
cualquier imagen de catálogo y no tienen problemas de licencia. Sugerencias:
- Una foto de la Raspberry Pi conectada al proyector.
- Una foto de la proyección en marcha durante un evento.
- Capturas de los distintos motores (Butterchurn, Hydra, Shaders, Mezclador).
Guárdalas en esta carpeta y enlázalas desde el `README.md` o desde la
documentación de `docs/`.

51
docs/assets/banner.svg Normal file
View file

@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="300" viewBox="0 0 960 300">
<defs>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="7" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<linearGradient id="bars" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b4ff00"/>
<stop offset="1" stop-color="#ff7a00"/>
</linearGradient>
</defs>
<rect width="960" height="300" fill="#060a06"/>
<!-- Ecualizador: barras reactivas al audio -->
<g fill="url(#bars)" opacity="0.55">
<rect x="8" y="270" width="24" height="30"/>
<rect x="48" y="252" width="24" height="48"/>
<rect x="88" y="232" width="24" height="68"/>
<rect x="128" y="258" width="24" height="42"/>
<rect x="168" y="278" width="24" height="22"/>
<rect x="208" y="245" width="24" height="55"/>
<rect x="248" y="218" width="24" height="82"/>
<rect x="288" y="264" width="24" height="36"/>
<rect x="328" y="272" width="24" height="28"/>
<rect x="368" y="238" width="24" height="62"/>
<rect x="408" y="254" width="24" height="46"/>
<rect x="448" y="228" width="24" height="72"/>
<rect x="488" y="266" width="24" height="34"/>
<rect x="528" y="248" width="24" height="52"/>
<rect x="568" y="276" width="24" height="24"/>
<rect x="608" y="234" width="24" height="66"/>
<rect x="648" y="260" width="24" height="40"/>
<rect x="688" y="242" width="24" height="58"/>
<rect x="728" y="268" width="24" height="32"/>
<rect x="768" y="222" width="24" height="78"/>
<rect x="808" y="262" width="24" height="38"/>
<rect x="848" y="254" width="24" height="46"/>
<rect x="888" y="236" width="24" height="64"/>
<rect x="928" y="274" width="24" height="26"/>
</g>
<text x="480" y="138" text-anchor="middle" font-family="Arial, Helvetica, sans-serif"
font-size="86" font-weight="800" letter-spacing="16"
fill="#b4ff00" filter="url(#glow)">FOSFENO</text>
<text x="480" y="182" text-anchor="middle" font-family="monospace"
font-size="19" letter-spacing="2" fill="#cfe8ad">motor de visuales audio-reactivas para raspberry pi</text>
<line x1="350" y1="204" x2="610" y2="204" stroke="#ff7a00" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

89
docs/conexion.md Normal file
View file

@ -0,0 +1,89 @@
# Conectarse al panel
Esta es la parte donde, sin cuidado, la gente se atasca: la Raspberry funciona,
las visuales salen por el proyector, pero el usuario no sabe en qué dirección
está el panel de control. FOSFENO está pensado para que ese paso no exista
como problema.
## La idea: encender y ver
Cuando la Raspberry arranca, antes de ponerse a dibujar visuales, el proyector
muestra una pantalla de conexión durante unos segundos. En esa pantalla salen
tres cosas:
- Un código QR grande.
- La dirección del panel.
- Un recordatorio de que el móvil tiene que estar en la misma red.
Esa misma pantalla vuelve a aparecer siempre que apagas las visuales desde el
panel. Así, si en mitad de un evento alguien necesita la dirección, basta con
darle a apagar y la tiene delante en el proyector.
## Tres formas de entrar, de la más fácil a la menos
**Escanear el código QR.** Abre la cámara del móvil, apúntala al QR que sale en
el proyector y toca el aviso que aparece. Se abre el panel. No hay que escribir
nada. Es la forma recomendada.
**Escribir el nombre.** En el navegador del móvil, escribe:
```
http://fosfeno.local/
```
Esta dirección no cambia nunca, da igual el día o la red. El instalador le pone
a la Raspberry el nombre de red `fosfeno`, y `fosfeno.local` es la forma
estándar de localizar un equipo por su nombre en la red local.
**Escribir la dirección IP.** Es la que aparece en la pantalla de conexión del
proyector, algo como `http://192.168.1.71/`. Funciona siempre, pero la IP puede
cambiar de un día para otro, así que es la opción de reserva.
## El móvil tiene que estar en la misma red
El panel es una página local: vive dentro de la Raspberry, no en internet. Para
abrirla, el móvil y la Raspberry tienen que estar conectados a la misma red:
el mismo router, ya sea por WiFi o por cable.
No funciona si el móvil está con datos móviles, ni si está en una red WiFi
distinta (por ejemplo, una red de invitados separada).
## Sobre `fosfeno.local`
El nombre `.local` funciona gracias a un sistema llamado mDNS, que ya viene
activado en Raspberry Pi OS. Lo entienden los iPhone, los Android modernos,
Windows y Mac sin instalar nada.
Si en algún móvil viejo `fosfeno.local` no abre, no pasa nada: usa el código QR
o la dirección IP, que aparecen igualmente en el proyector.
Puedes cambiar ese nombre. Edita `config.json`, el apartado `network.hostname`,
y vuelve a ejecutar `bash install.sh`. El panel quedará entonces en
`http://el-nombre-que-pongas.local/`.
## Sobre el router
No hace falta tocar nada en el router. FOSFENO no abre puertos hacia internet
ni necesita configuración especial. Solo usa la red local.
Un único ajuste es recomendable, aunque opcional: entrar en el router y
reservarle a la Raspberry siempre la misma IP (lo que se suele llamar reserva
DHCP o IP fija). Así la dirección IP no cambia nunca. De todas formas, como el
nombre `fosfeno.local` ya es fijo, puedes saltarte esto sin problema.
Para un evento, el cable de red entre la Raspberry y el router es más fiable
que el WiFi.
## Si no consigues conectar
Repasa estas cosas, en orden:
- El móvil está en la misma red WiFi que la Raspberry, no en datos móviles.
- Has escrito `http://` delante, y la barra `/` al final.
- Prueba las tres vías: primero el QR, si no el nombre, si no la IP.
- Mira la pantalla de conexión del proyector: la dirección que sale ahí es la
buena en este momento.
Si el proyector no muestra ni siquiera la pantalla de conexión, el problema no
es de red sino de arranque; en ese caso mira la guía de
[solución de problemas](problemas.md).

121
docs/instalacion.md Normal file
View file

@ -0,0 +1,121 @@
# Instalación
La instalación es un único comando. Aun así, conviene saber qué hace por
dentro, porque descarga y compila bastantes cosas y el proceso tarda.
## Antes de empezar
Necesitas Raspberry Pi OS Bookworm de 64 bits con el escritorio instalado, no
la versión Lite. FOSFENO muestra las visuales dentro de una ventana de
Chromium a pantalla completa, así que hace falta entorno gráfico.
Conviene tener la Raspberry conectada a internet por cable durante la
instalación. Se descargan paquetes del sistema, librerías y el código fuente
de projectM.
## El comando
```
git clone <URL-de-tu-gitlab>/fosfeno.git
cd fosfeno
bash install.sh
```
El instalador va informando de cada paso con una marca delante:
- `[ OK ]` el paso terminó bien.
- `[ !! ]` hubo un aviso. No es grave, pero conviene leerlo.
- `[ XX ]` hubo un fallo que detiene la instalación.
Al final resume cuántos avisos hubo. Si todo sale con `[ OK ]`, la
instalación está limpia.
## Qué se instala automáticamente
El script trabaja en nueve fases. Esto es lo que hace cada una.
**Comprobación del sistema.** Detecta si la placa es una Pi 4 o una Pi 5, mira
la versión del sistema operativo y la arquitectura. Si algo no encaja avisa,
pero te deja seguir.
**Paquetes del sistema.** Instala con apt lo que hace falta para que todo lo
demás funcione:
- Python 3 y su gestor de entornos virtuales.
- Chromium, el navegador donde se dibujan las visuales.
- Node.js y npm, para descargar las librerías de visuales.
- Git, CMake y las herramientas de compilación, necesarias para projectM.
- Las librerías de desarrollo de SDL2, OpenGL ES y POCO, que projectM usa al
compilar.
- Las utilidades de PulseAudio, para detectar el micrófono.
- v4l-utils, para reconocer la cámara USB.
- xdotool y unclutter, para el control de projectM y para esconder el ratón.
**Verificación de versiones.** Comprueba que Python, Node, npm y CMake
cumplen una versión mínima. Si alguna se queda corta lo dice claramente.
Cuando es CMake quien no llega, salta la compilación de projectM en lugar de
fallar entera.
**Entorno de Python.** Crea un entorno virtual aislado e instala Flask y
Flask-SocketIO, que son el servidor web del proyecto. Después comprueba que
esas librerías se importan de verdad.
**Librerías de visuales.** Con npm descarga Butterchurn y sus presets, Hydra,
el cliente de Socket.IO y CodeMirror, que es el editor de código del panel.
Copia los archivos que se usan a una carpeta `web/lib` y verifica uno por uno
que cada copia existe y no está vacía.
**projectM.** Compila projectM desde su código fuente. Esto es lo que más
tarda, entre diez y veinte minutos en una Raspberry. Si la compilación falla,
el instalador lo avisa y sigue adelante: FOSFENO funciona igual con los otros
cuatro motores. Puedes saltarte este paso desde el principio con
`bash install.sh --no-projectm`.
**Recursos.** Descarga un paquete de presets para projectM y crea la carpeta
`data/videos`, donde copiarás tus clips para el modo Mezclador.
**Arranque automático.** Configura la Raspberry para que, al encender el
escritorio, lance FOSFENO sola. También da permiso para reiniciar y apagar la
placa desde el panel sin pedir contraseña.
**Puerto 80.** Da permiso a Python para usar el puerto 80, que es lo que
permite abrir el panel en `http://la-ip-de-tu-pi/` sin escribir ningún número
de puerto detrás.
## Después de instalar
Falta un ajuste que el script no puede hacer por ti. Hay que decirle a la
Raspberry que arranque sola en el escritorio:
```
sudo raspi-config
```
Dentro, ve a `System Options`, luego `Boot / Auto Login`, y elige
`Desktop Autologin`. Sal y reinicia:
```
sudo reboot
```
Al volver, la Raspberry arranca directa en modo kiosko mostrando las
visuales. El panel queda disponible en `http://la-ip-de-tu-pi/`.
## Comprobar el sistema sin reinstalar
Si quieres revisar que todo sigue en su sitio, o diagnosticar un problema, hay
un modo que solo comprueba y no toca nada:
```
bash install.sh --check
```
Lista las versiones de las herramientas y dice si projectM y Chromium están
presentes.
## Volver a ejecutar el instalador
El script se puede ejecutar las veces que quieras. No repite el trabajo ya
hecho: no vuelve a compilar projectM si ya está, ni vuelve a descargar los
presets si ya están. Es seguro relanzarlo si una instalación se cortó a
medias.

117
docs/problemas.md Normal file
View file

@ -0,0 +1,117 @@
# Solución de problemas
FOSFENO intenta no quedarse callado cuando algo va mal. La mayoría de fallos
salen como aviso en la parte de arriba del panel. Aun así, aquí tienes los
casos más habituales y cómo resolverlos.
## El panel no carga
Comprueba que el móvil y la Raspberry están en la misma red. Prueba a entrar
con la IP exacta de la Raspberry. Si configuraste el puerto 8080, recuerda
escribir `:8080` detrás de la dirección.
Si la IP responde pero la página no aparece, puede que el servidor no haya
arrancado. Conéctate a la Raspberry y míralo a mano:
```
cd ~/fosfeno
.venv/bin/python3 backend/server.py
```
Los mensajes que imprime ahí, los que empiezan por `[FOSFENO]`, dicen qué
está pasando.
## No se ven visuales en el proyector
Mira si el proyector tiene señal. Si está en negro pero el panel funciona,
revisa que las visuales estén encendidas con el botón de encendido.
Si la pantalla está en negro y en el panel no hay forma de que aparezca nada,
Chromium puede no haberse abierto. El servidor lo reabre solo cada pocos
segundos, así que espera un poco. Si no, reinicia la Raspberry desde el panel
o desde la consola.
## No hay sonido o el BPM marca cero
El BPM en cero quiere decir que FOSFENO no está recibiendo audio, o que el
audio es demasiado flojo.
Comprueba que el micrófono USB está conectado. En el apartado Audio del panel,
abre el desplegable y elige a mano la entrada correcta. Si tu micrófono no
aparece, conéctalo y reinicia la Raspberry, porque la lista se hace al
arrancar.
Si el audio entra pero el BPM no se estabiliza, sube la sensibilidad. El
detector de ritmo necesita música con un pulso claro; con música muy suave o
sin percusión puede no encontrar el tempo, y a veces marca el doble o la
mitad del valor real.
## La cámara no funciona en el Mezclador
Tiene que ser una webcam USB que cumpla el estándar UVC. El módulo de cámara
con cable plano de la Raspberry no sirve en esta versión.
Conecta la cámara antes de encender la Raspberry. Si la conectas con el
sistema ya arrancado, reinicia. Si en el panel sale un aviso de que la cámara
no responde, es que el sistema no la ve; pruébala en otro puerto USB o con
otro cable.
## projectM no está disponible
Si al elegir projectM sale un aviso de que no está instalado, es que su
compilación falló o se saltó durante la instalación. No es grave: los otros
cuatro motores funcionan igual.
Para intentar compilarlo de nuevo:
```
cd ~/fosfeno
bash scripts/build-projectm.sh
```
Ese script avisa en qué paso falla. Lo más habitual es que falte alguna
librería de desarrollo; vuelve a lanzar `bash install.sh` para reinstalar las
dependencias del sistema.
## Las visuales van a tirones
Suele ser calor. Una Raspberry sin disipador, después de un rato con visuales,
se calienta y baja su velocidad para protegerse. Ponle disipador o ventilador.
También ayuda usar motores más ligeros. Butterchurn e Hydra van más sueltos
que los shaders pesados o que el mezclador de vídeo. En el Mezclador, usa
clips de vídeo en 720p o menos.
## Un shader o un código de Hydra da error
Cuando un shader no compila, o un código de Hydra falla, sale un aviso en el
panel diciéndolo. La descripción detallada del error aparece en la consola del
navegador de la Raspberry, no en el panel, porque suele ser un mensaje
técnico largo.
Revisa el código. Si lo pegaste de fuera, comprueba que es código de Hydra y
no de otra herramienta, y que está completo.
## Revisar el estado del sistema
Para comprobar de una vez si las herramientas están bien instaladas:
```
cd ~/fosfeno
bash install.sh --check
```
No instala nada. Solo informa de las versiones y de si projectM y Chromium
están presentes.
## Empezar de cero
Si quieres quitar FOSFENO sin borrar el código:
```
cd ~/fosfeno
bash uninstall.sh
```
Eso quita el arranque automático y los permisos, y cierra los procesos. El
código sigue en su carpeta por si quieres volver a instalarlo.

97
docs/requisitos.md Normal file
View file

@ -0,0 +1,97 @@
# Requisitos y hardware
FOSFENO funciona en una Raspberry Pi conectada a un proyector. La Raspberry
escucha el sonido de la sala a través de un micrófono USB y dibuja visuales
que reaccionan a la música. Todo se gobierna desde el móvil con un navegador.
## Raspberry Pi
Sirve una Raspberry Pi 4 o una Raspberry Pi 5. La Pi 5 va más holgada con los
shaders y el mezclador de vídeo, así que es la recomendada si vas a usar esos
modos a menudo. La Pi 4 cumple de sobra para Butterchurn e Hydra.
Necesitas además:
- La fuente de alimentación oficial. Los visuales exigen GPU y CPU; con
cargadores genéricos la Pi puede reiniciarse sola.
- Una tarjeta microSD de 32 GB o más, o mejor un SSD por USB en la Pi 5.
- Disipador o ventilador. Mantener visuales en bucle calienta la placa, y si
se calienta de más reduce su velocidad y las visuales van a tirones.
- Sistema operativo Raspberry Pi OS Bookworm de 64 bits, con escritorio.
## Conexión con el proyector
La Pi 4 y la Pi 5 llevan salida micro-HDMI, no HDMI normal. Necesitas un cable
micro-HDMI a HDMI, o un adaptador. Cualquier proyector con entrada HDMI vale.
La resolución la ajusta el sistema; si el proyector es de 1280x720 las
visuales irán más finas que a 1080p.
## Red
El panel de control se abre desde el móvil, así que el móvil y la Raspberry
tienen que estar en la misma red. El cable Ethernet es más estable que el
WiFi para un evento, pero las dos opciones funcionan. Conviene fijar la IP de
la Raspberry en el router para que no cambie entre un día y otro.
## Micrófono USB y audio
La Raspberry Pi no tiene entrada de audio. El conector jack de la placa es
solo salida. Para que FOSFENO oiga la música hay que añadir una entrada por
USB.
Funcionan sin instalar nada los dispositivos que cumplen el estándar USB
Audio Class. En la práctica eso es casi todo lo que se vende hoy:
- Tarjetas de sonido USB. Son adaptadores pequeños con un USB por un lado y
conectores jack por el otro. Las que traen entrada de micrófono o entrada
de línea sirven para enchufar el sonido desde una mesa de mezclas o desde
un móvil. Dan la señal más limpia.
- Micrófonos USB. Los micrófonos de condensador tipo podcast (Fifine, Samson,
Blue Snowball y similares) se conectan directos y captan el sonido de la
sala. Es la opción más cómoda si no quieres tirar cables.
- Interfaces de audio USB y auriculares USB con micro. También valen mientras
sean USB Audio Class, que es lo normal.
Evita los micrófonos Bluetooth. Tienen retardo y FOSFENO no los selecciona de
forma automática. Evita también cualquier aparato que pida un driver del
fabricante; con audio es raro, pero los hay.
FOSFENO elige la entrada automáticamente al arrancar. Busca entre las fuentes
de audio del sistema la primera cuyo nombre contenga la palabra `usb`. Si tu
dispositivo no se llama así, puedes cambiar esa palabra clave en `config.json`,
en el apartado `audio.matchSource`. Desde el panel también puedes elegir a
mano cualquier entrada detectada.
Recomendación práctica: para un evento con mesa de DJ, una tarjeta USB con
entrada de línea conectada por jack a la salida de la mesa. Para algo casero o
una fiesta, un micrófono USB que recoja el ambiente.
## Cámara USB
La cámara solo hace falta para el modo Mezclador. FOSFENO está preparado para
cámaras web USB.
Funcionan sin drivers las cámaras que cumplen el estándar USB Video Class,
conocido como UVC. Prácticamente todas las cámaras web USB modernas lo
cumplen: las de Logitech, las genéricas, las de portátil externas. Conéctala
antes de encender la Raspberry y el sistema la reconoce sola. El navegador la
ve a través de V4L2, el subsistema de vídeo de Linux.
Esta versión de FOSFENO no usa el módulo de cámara oficial de la Raspberry, el
que va con cable plano al conector CSI. En Raspberry Pi OS Bookworm ese módulo
funciona a través de libcamera y no aparece como cámara estándar para el
navegador sin pasos extra. Si quieres cámara, usa una webcam USB UVC.
Recomendación práctica: cualquier webcam USB de 720p sirve. No hace falta
nada caro. Si la cámara permite varias resoluciones, el navegador elige una
compatible automáticamente.
## Resumen de la lista de la compra
- Raspberry Pi 4 o 5 con su fuente oficial.
- microSD de 32 GB o SSD USB.
- Disipador o ventilador.
- Cable micro-HDMI a HDMI.
- Micrófono USB o tarjeta de sonido USB con entrada.
- Webcam USB UVC, solo si vas a usar el modo Mezclador.
- Cable de red, o WiFi.

103
docs/uso.md Normal file
View file

@ -0,0 +1,103 @@
# Uso del panel
Cuando la Raspberry arranca, las visuales salen solas por el proyector. Todo
lo demás se controla desde el panel.
## Entrar en el panel
Al arrancar, el proyector muestra una pantalla con un código QR y la dirección
del panel. Escanea el QR con la cámara del móvil y el panel se abre solo. Si
prefieres escribirla, la dirección es `http://fosfeno.local/`.
El móvil tiene que estar en la misma red que la Raspberry. La explicación
completa, con las tres formas de conectar y los detalles del router, está en
[Conectarse al panel](conexion.md).
Arriba a la derecha del panel hay un punto. Verde quiere decir que está
conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión.
## Avisos y errores
Justo debajo de la cabecera aparecen los avisos. Si algo va mal (la cámara no
responde, un shader tiene un error de código, projectM no está instalado), el
mensaje sale ahí en lugar de quedarse el sistema callado. Los avisos
informativos desaparecen solos. Los errores se quedan hasta que los cierras,
para que no se te escapen.
## Encendido
El botón grande de encendido pone las visuales en marcha o las apaga. Apagar
deja el proyector en negro sin tener que apagar la Raspberry.
## Los cinco motores
Se eligen con los botones redondos. Solo uno está activo a la vez.
**projectM.** El visualizador clásico de MilkDrop, compilado en la propia
Raspberry. Reacciona al audio por sí solo y va rotando entre miles de
presets. Es un programa nativo, así que cuando lo eliges su ventana se pone
por encima de las demás visuales.
**Butterchurn.** Es MilkDrop reescrito para el navegador. Tiene los mismos
miles de presets. Puedes elegir un preset concreto, dejar que cambie solo cada
ciertos segundos, o que cambie sincronizado con el ritmo de la música.
**Hydra.** Visuales generados por código. Trae un editor integrado donde
puedes escribir o pegar código de Hydra y ejecutarlo al momento. Incluye una
librería de fragmentos listos para usar; eliges uno y se carga en el editor.
**Shaders.** Shaders GLSL, el tipo de visual de Shadertoy. También trae editor
de código. Los shaders reciben información del audio y del ritmo, así que se
mueven con la música.
**Mezclador.** El modo de vídeo. Mezcla la imagen de una webcam USB con clips
de vídeo y efectos de color. Es lo más parecido a un programa de VJ como
Resolume, pero funcionando dentro de la Raspberry.
## Audio y BPM
La tarjeta de audio se elige en el apartado Audio del panel. Por defecto
FOSFENO coge el micrófono USB automáticamente, pero si tienes varias entradas
puedes cambiarla ahí.
Justo al lado se ve el BPM detectado. FOSFENO analiza el sonido y estima a
cuántos pulsos por minuto va la música. Ese valor lo usan los motores para
sincronizarse: los shaders, Hydra y el cambio de preset de Butterchurn al
compás. El BPM tarda unos segundos en estabilizarse y funciona mejor con
música de pulso marcado.
La sensibilidad ajusta cuánto reaccionan las visuales al volumen. Si la sala
suena floja, súbela. Si todo se ve saturado, bájala.
## El editor de código
En Hydra y en Shaders aparece un editor. Funciona igual en los dos:
- El desplegable de la librería carga un ejemplo. Al elegirlo, el código entra
en el editor y se ejecuta al momento.
- Puedes modificar ese código o pegar uno tuyo. El botón Ejecutar lanza lo que
haya en el editor.
- El botón Limpiar vacía el editor.
En Hydra el código es JavaScript de Hydra y tienes disponibles `time`, los
valores de audio `a.fft[0]` a `a.fft[4]` y la variable `bpm`. En Shaders el
código es GLSL y tienes los uniforms `u_time`, `u_bass`, `u_mid`, `u_treble`,
`u_bpm`, `u_beat` y la textura `u_fft`.
## El modo Mezclador
Primero copia tus clips de vídeo en la carpeta `data/videos` del proyecto.
Aparecen solos en el desplegable de vídeo del panel. Para que vayan finos en
la Raspberry conviene que sean clips cortos, en 720p o menos y en H.264.
En el panel eliges la fuente: solo la cámara, solo el vídeo, o la mezcla de
las dos. Debajo tienes el modo de mezcla y una fila de controles de color:
tono, saturación, contraste, brillo, colorama, posterizado, pixelado,
caleidoscopio, rotación y feedback. La casilla de pulso al ritmo hace que la
imagen lata con los graves.
## Apagar y reiniciar
Abajo del todo están los botones para reiniciar y apagar la Raspberry. Piden
confirmación. Apagar desde aquí es la forma correcta de apagar la placa al
terminar, mejor que cortar la corriente.

290
install.sh Executable file
View file

@ -0,0 +1,290 @@
#!/usr/bin/env bash
# ===========================================================================
# FOSFENO :: instalador para Raspberry Pi OS Bookworm (Raspberry Pi 4 y 5)
#
# Uso: bash install.sh # instala todo (incluido projectM)
# bash install.sh --no-projectm # omite la compilacion de projectM
# bash install.sh --check # solo comprueba el sistema, no instala
# ===========================================================================
set -uo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
source "$DIR/scripts/lib.sh"
MODE="install"
SKIP_PM="no"
for arg in "$@"; do
case "$arg" in
--no-projectm) SKIP_PM="yes" ;;
--check) MODE="check" ;;
*) echo "Opcion desconocida: $arg"; exit 1 ;;
esac
done
# Versiones minimas requeridas
MIN_PYTHON="3.9"
MIN_NODE="16"
MIN_NPM="8"
MIN_CMAKE="3.21"
printf '%s\n' "$C_BLD==================================================="
printf '%s\n' " FOSFENO :: instalador ($DIR)"
printf '%s\n' "===================================================$C_RST"
# ---------------------------------------------------------------------------
# 1. Comprobacion del hardware y del sistema operativo
# ---------------------------------------------------------------------------
log_step "[1/9] Comprobando hardware y sistema operativo"
MODELO="$(pi_model)"
log_info "Modelo detectado: $MODELO"
case "$MODELO" in
*"Raspberry Pi 5"*) log_ok "Raspberry Pi 5 (soportada)" ;;
*"Raspberry Pi 4"*) log_ok "Raspberry Pi 4 (soportada)" ;;
*"Raspberry Pi"*) warn "Raspberry Pi distinta de 4/5: puede funcionar pero sin garantias" ;;
*) warn "No parece una Raspberry Pi: continuando bajo tu responsabilidad" ;;
esac
if [ -r /etc/os-release ]; then
. /etc/os-release
log_info "Sistema: ${PRETTY_NAME:-desconocido}"
if [ "${VERSION_CODENAME:-}" = "bookworm" ]; then
log_ok "Raspberry Pi OS Bookworm"
else
warn "Se recomienda Raspberry Pi OS Bookworm (detectado: ${VERSION_CODENAME:-?})"
fi
fi
ARCH="$(uname -m)"
log_info "Arquitectura: $ARCH"
[ "$ARCH" = "aarch64" ] && log_ok "Sistema de 64 bits" \
|| warn "Se recomienda Raspberry Pi OS de 64 bits para mejor rendimiento"
# ---------------------------------------------------------------------------
# Modo --check: solo verifica lo que ya esta instalado y termina
# ---------------------------------------------------------------------------
if [ "$MODE" = "check" ]; then
log_step "Comprobando herramientas ya instaladas"
require_version "Python" "python3 --version" "$MIN_PYTHON" || true
require_version "Node.js" "node --version" "$MIN_NODE" || true
require_version "npm" "npm --version" "$MIN_NPM" || true
require_version "CMake" "cmake --version" "$MIN_CMAKE" || true
if need_cmd chromium-browser || need_cmd chromium; then
log_ok "Chromium presente"
else
log_fail "Chromium no encontrado"
fi
need_cmd git && log_ok "git presente" || log_fail "git no encontrado"
need_cmd projectMSDL && log_ok "projectM (projectMSDL) presente" \
|| log_warn "projectM no instalado (opcional)"
printf '\n'
[ "$FOSFENO_WARNINGS" -eq 0 ] && log_ok "Comprobacion terminada sin avisos" \
|| log_warn "Comprobacion terminada con $FOSFENO_WARNINGS aviso(s)"
exit 0
fi
# ---------------------------------------------------------------------------
# 2. Paquetes del sistema
# ---------------------------------------------------------------------------
log_step "[2/9] Instalando dependencias del sistema (apt)"
sudo apt-get update
if sudo apt-get install -y \
python3 python3-venv python3-pip \
chromium-browser \
nodejs npm \
git cmake build-essential pkg-config \
libsdl2-dev libgles2-mesa-dev mesa-common-dev libglm-dev libpoco-dev \
pulseaudio-utils \
v4l-utils \
avahi-daemon \
xdotool unclutter; then
log_ok "Paquetes apt instalados"
else
log_fail "Fallo al instalar paquetes apt"
exit 1
fi
# ---------------------------------------------------------------------------
# 3. Verificacion de versiones de las herramientas
# ---------------------------------------------------------------------------
log_step "[3/9] Verificando versiones de las herramientas"
require_version "Python" "python3 --version" "$MIN_PYTHON" \
|| { log_fail "Python demasiado antiguo; aborto"; exit 1; }
require_version "Node.js" "node --version" "$MIN_NODE" \
|| warn "Node.js antiguo: 'npm install' podria fallar"
require_version "npm" "npm --version" "$MIN_NPM" || true
if [ "$SKIP_PM" = "no" ]; then
require_version "CMake" "cmake --version" "$MIN_CMAKE" \
|| { warn "CMake antiguo: projectM no se compilara"; SKIP_PM="yes"; }
fi
# ---------------------------------------------------------------------------
# 4. Entorno Python
# ---------------------------------------------------------------------------
log_step "[4/9] Creando el entorno virtual de Python"
python3 -m venv "$DIR/.venv"
"$DIR/.venv/bin/pip" install --quiet --upgrade pip
"$DIR/.venv/bin/pip" install --quiet -r "$DIR/backend/requirements.txt"
if "$DIR/.venv/bin/python3" -c "import flask, flask_socketio" 2>/dev/null; then
log_ok "Entorno Python listo (flask, flask-socketio)"
else
log_fail "Las dependencias de Python no se importan correctamente"
exit 1
fi
# ---------------------------------------------------------------------------
# 5. Librerias web (Butterchurn, Hydra, Socket.IO, CodeMirror)
# ---------------------------------------------------------------------------
log_step "[5/9] Descargando librerias de visuales (npm)"
cd "$DIR/web"
if npm install --no-audit --no-fund --loglevel=error; then
log_ok "Paquetes npm descargados"
else
log_fail "Fallo en 'npm install'"
exit 1
fi
mkdir -p "$DIR/web/lib/codemirror"
# Copia un fichero y comprueba que existe y no esta vacio
copy_lib() { # copy_lib <origen> <destino> <descripcion>
if cp "$1" "$2" 2>/dev/null && [ -s "$2" ]; then
log_ok "$3"
else
warn "no se pudo copiar: $3 ($1)"
fi
}
copy_lib node_modules/butterchurn/dist/butterchurn.min.js \
lib/butterchurn.min.js "Butterchurn"
copy_lib node_modules/butterchurn-presets/dist/base.min.js \
lib/butterchurn-presets.min.js "Presets de Butterchurn"
copy_lib node_modules/socket.io-client/dist/socket.io.min.js \
lib/socket.io.min.js "Socket.IO"
copy_lib node_modules/qrcode-generator/qrcode.js \
lib/qrcode.js "Generador de codigo QR"
copy_lib node_modules/codemirror/lib/codemirror.js \
lib/codemirror/codemirror.js "CodeMirror (editor)"
copy_lib node_modules/codemirror/lib/codemirror.css \
lib/codemirror/codemirror.css "CodeMirror (estilos)"
copy_lib node_modules/codemirror/mode/javascript/javascript.js \
lib/codemirror/javascript.js "CodeMirror (modo JavaScript)"
copy_lib node_modules/codemirror/mode/clike/clike.js \
lib/codemirror/clike.js "CodeMirror (modo GLSL)"
copy_lib node_modules/codemirror/theme/material-darker.css \
lib/codemirror/material-darker.css "CodeMirror (tema)"
# Hydra no siempre ubica el build en el mismo sitio: probamos varias rutas
if cp node_modules/hydra-synth/dist/hydra-synth.js lib/hydra-synth.js 2>/dev/null && [ -s lib/hydra-synth.js ]; then
log_ok "Hydra"
elif cp node_modules/hydra-synth/build/hydra-synth.js lib/hydra-synth.js 2>/dev/null && [ -s lib/hydra-synth.js ]; then
log_ok "Hydra (build/)"
else
warn "no se encontro el build de hydra-synth en node_modules; revisa la ruta"
fi
cd "$DIR"
# ---------------------------------------------------------------------------
# 6. projectM nativo (compilado desde fuente)
# ---------------------------------------------------------------------------
log_step "[6/9] projectM (visualizador nativo)"
if [ "$SKIP_PM" = "yes" ]; then
log_info "projectM omitido"
elif command -v projectMSDL >/dev/null 2>&1; then
log_ok "projectM ya estaba instalado ($(command -v projectMSDL))"
else
log_info "Compilando projectM desde fuente (puede tardar 10-20 min en una Pi)..."
if bash "$DIR/scripts/build-projectm.sh"; then
command -v projectMSDL >/dev/null 2>&1 \
&& log_ok "projectM compilado e instalado" \
|| warn "projectM compilo pero 'projectMSDL' no esta en el PATH"
else
warn "projectM no se pudo compilar; FOSFENO seguira con Butterchurn, Hydra y Shaders"
fi
fi
# ---------------------------------------------------------------------------
# 7. Presets de projectM y carpeta de videos
# ---------------------------------------------------------------------------
log_step "[7/9] Recursos (presets de projectM, carpeta de videos)"
mkdir -p "$DIR/data/presets-projectm" "$DIR/data/videos"
if [ -z "$(ls -A "$DIR/data/presets-projectm" 2>/dev/null)" ]; then
if git clone --depth 1 \
https://github.com/projectM-visualizer/presets-cream-of-the-crop.git \
"$DIR/data/presets-projectm" 2>/dev/null; then
N=$(find "$DIR/data/presets-projectm" -name '*.milk' | wc -l)
log_ok "Presets de projectM descargados ($N presets)"
else
warn "no se pudieron descargar los presets de projectM"
fi
else
log_ok "Presets de projectM ya presentes"
fi
log_info "Copia tus clips .mp4 en: $DIR/data/videos/"
# ---------------------------------------------------------------------------
# 8. Arranque automatico y permisos
# ---------------------------------------------------------------------------
log_step "[8/9] Configurando arranque automatico y permisos"
chmod +x "$DIR/scripts/"*.sh "$DIR/install.sh" "$DIR/uninstall.sh"
mkdir -p "$HOME/.config/autostart"
if sed "s#__DIR__#$DIR#g" "$DIR/scripts/fosfeno-autostart.desktop" \
> "$HOME/.config/autostart/fosfeno.desktop"; then
log_ok "Arranque automatico configurado (~/.config/autostart/fosfeno.desktop)"
fi
# Nombre de red fijo: deja el panel accesible en http://<hostname>.local/
HOSTNAME_WANT="$(python3 -c "import json;print(json.load(open('$DIR/config.json')).get('network',{}).get('hostname','fosfeno'))" 2>/dev/null || echo fosfeno)"
if [ "$(hostname)" != "$HOSTNAME_WANT" ]; then
sudo hostnamectl set-hostname "$HOSTNAME_WANT" 2>/dev/null || true
if grep -q "^127.0.1.1" /etc/hosts; then
sudo sed -i "s/^127.0.1.1.*/127.0.1.1\t$HOSTNAME_WANT/" /etc/hosts
else
echo -e "127.0.1.1\t$HOSTNAME_WANT" | sudo tee -a /etc/hosts >/dev/null
fi
log_ok "Nombre de red puesto a '$HOSTNAME_WANT' (panel en http://$HOSTNAME_WANT.local/)"
else
log_ok "Nombre de red: $HOSTNAME_WANT"
fi
if echo "$USER ALL=(ALL) NOPASSWD: /sbin/reboot, /sbin/poweroff" \
| sudo tee /etc/sudoers.d/fosfeno >/dev/null \
&& sudo chmod 440 /etc/sudoers.d/fosfeno; then
log_ok "Permisos de reinicio/apagado configurados"
fi
# ---------------------------------------------------------------------------
# 9. Permiso para el puerto 80
# ---------------------------------------------------------------------------
log_step "[9/9] Permitiendo a Python escuchar en el puerto 80"
PYBIN="$(readlink -f "$DIR/.venv/bin/python3")"
if sudo setcap 'cap_net_bind_service=+ep' "$PYBIN" 2>/dev/null; then
log_ok "Puerto 80 habilitado"
else
warn "no se pudo habilitar el puerto 80; cambia 'server.port' a 8080 en config.json"
fi
# ---------------------------------------------------------------------------
# Resumen final
# ---------------------------------------------------------------------------
printf '\n%s\n' "$C_BLD==================================================="
if [ "$FOSFENO_WARNINGS" -eq 0 ]; then
log_ok "FOSFENO instalado correctamente, sin avisos."
else
log_warn "FOSFENO instalado con $FOSFENO_WARNINGS aviso(s) (revisa arriba)."
fi
printf '%s\n' "===================================================$C_RST"
cat <<EOF
Siguientes pasos:
1. Activa el login automatico al escritorio:
sudo raspi-config -> System Options -> Boot / Auto Login
-> Desktop Autologin
2. Reinicia: sudo reboot
Como entrar en el panel de control:
- Al arrancar, el proyector muestra un codigo QR y la direccion.
Escanea el QR con el movil y se abre el panel. Mas facil imposible.
- Si prefieres escribirla: http://$HOSTNAME_WANT.local/
- El movil debe estar en la misma red (WiFi o cable) que la Raspberry.
Para volver a comprobar el sistema sin reinstalar:
bash install.sh --check
EOF

29
scripts/build-projectm.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Compila projectM v4 + su frontend SDL2 (projectMSDL) desde el codigo fuente.
# Lo invoca install.sh. Tarda bastante en una Raspberry Pi.
set -e
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
cd "$WORK"
echo "==> [projectM] Clonando y compilando la libreria projectM v4..."
git clone --depth 1 --recurse-submodules \
https://github.com/projectM-visualizer/projectm.git
cd projectm
cmake -B build -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr/local -DENABLE_GLES=ON
cmake --build build --parallel "$(nproc)"
sudo cmake --install build
sudo ldconfig
cd "$WORK"
echo "==> [projectM] Clonando y compilando el frontend SDL2 (projectMSDL)..."
git clone --depth 1 --recurse-submodules \
https://github.com/projectM-visualizer/frontend-sdl2.git
cd frontend-sdl2
cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build build --parallel "$(nproc)"
sudo cmake --install build
echo "==> [projectM] Instalado correctamente."

View file

@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=FOSFENO
Comment=Motor de visuales audio-reactivas
Exec=__DIR__/scripts/start-fosfeno.sh
X-GNOME-Autostart-enabled=true
NoDisplay=true

71
scripts/lib.sh Executable file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env bash
# ===========================================================================
# FOSFENO :: lib.sh - funciones comunes para los scripts de instalacion
# Se carga con: source "$(dirname "$0")/scripts/lib.sh"
# ===========================================================================
# --- Colores (se desactivan si la salida no es un terminal) ----------------
if [ -t 1 ]; then
C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'
C_CYA=$'\e[36m'; C_BLD=$'\e[1m'; C_RST=$'\e[0m'
else
C_RED=''; C_GRN=''; C_YEL=''; C_CYA=''; C_BLD=''; C_RST=''
fi
# --- Logging ----------------------------------------------------------------
log_step() { printf '\n%s==> %s%s\n' "$C_CYA$C_BLD" "$*" "$C_RST"; }
log_ok() { printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$*"; }
log_warn() { printf ' %s[ !! ]%s %s\n' "$C_YEL" "$C_RST" "$*"; }
log_fail() { printf ' %s[ XX ]%s %s\n' "$C_RED" "$C_RST" "$*"; }
log_info() { printf ' %s-%s %s\n' "$C_CYA" "$C_RST" "$*"; }
# Contador global de errores no fatales
FOSFENO_WARNINGS=0
warn() { log_warn "$*"; FOSFENO_WARNINGS=$((FOSFENO_WARNINGS + 1)); }
# --- Versiones --------------------------------------------------------------
# ver_of "salida cualquiera v1.2.3 bla" -> "1.2.3"
ver_of() { printf '%s' "$1" | grep -oE '[0-9]+(\.[0-9]+){1,2}' | head -n1; }
# ver_ge "1.2.3" "1.2.0" -> exit 0 si $1 >= $2
ver_ge() {
[ "$1" = "$2" ] && return 0
local menor
menor=$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)
[ "$menor" = "$2" ]
}
# require_version <nombre> <comando --version> <version_minima>
# Comprueba que un comando existe y cumple la version minima.
require_version() {
local nombre="$1" cmd="$2" minima="$3" salida actual
if ! command -v "${cmd%% *}" >/dev/null 2>&1; then
log_fail "$nombre no esta instalado"
return 1
fi
salida=$($cmd 2>&1 | head -n3)
actual=$(ver_of "$salida")
if [ -z "$actual" ]; then
warn "$nombre instalado pero no se pudo leer la version"
return 0
fi
if ver_ge "$actual" "$minima"; then
log_ok "$nombre $actual (minimo $minima)"
return 0
else
log_fail "$nombre $actual es anterior al minimo requerido $minima"
return 1
fi
}
# --- Hardware ---------------------------------------------------------------
pi_model() {
if [ -r /proc/device-tree/model ]; then
tr -d '\0' < /proc/device-tree/model
else
echo "desconocido"
fi
}
# --- Utilidades -------------------------------------------------------------
need_cmd() { command -v "$1" >/dev/null 2>&1; }

17
scripts/start-fosfeno.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Arranque de FOSFENO. Lo ejecuta el escritorio de Raspberry Pi OS al iniciar
# (ver scripts/fosfeno-autostart.desktop, copiado a ~/.config/autostart/).
DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Oculta el cursor del raton tras 1s de inactividad
command -v unclutter >/dev/null 2>&1 && unclutter -idle 1 &
# Evita que la pantalla se apague o entre en ahorro de energia (sesion X11)
xset s off 2>/dev/null
xset -dpms 2>/dev/null
xset s noblank 2>/dev/null
# Espera a que la red este lista (para que el panel sea accesible)
sleep 5
exec "$DIR/.venv/bin/python3" "$DIR/backend/server.py"

13
scripts/start-projectm.sh Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Lanza projectM nativo a pantalla completa. Lo invoca el servidor cuando
# se selecciona el motor "projectM" desde el panel.
DIR="$(cd "$(dirname "$0")/.." && pwd)"
PRESETS="$DIR/data/presets-projectm"
# projectMSDL (frontend SDL2 de projectM v4). La ruta de presets se puede
# fijar tambien en ~/.config/projectMSDL/projectMSDL.properties
if [ -d "$PRESETS" ]; then
exec projectMSDL --presetPath "$PRESETS"
else
exec projectMSDL
fi

16
uninstall.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Desinstala FOSFENO (no borra el repositorio ni el codigo).
DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> Eliminando el arranque automatico..."
rm -f "$HOME/.config/autostart/fosfeno.desktop"
echo "==> Eliminando los permisos de sudo..."
sudo rm -f /etc/sudoers.d/fosfeno
echo "==> Cerrando procesos en marcha..."
pkill -f "backend/server.py" 2>/dev/null || true
pkill -f "projectMSDL" 2>/dev/null || true
echo "==> FOSFENO desinstalado. El codigo sigue en $DIR"
echo " Para borrarlo todo: rm -rf $DIR/.venv $DIR/web/node_modules $DIR/web/lib"

14
web/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "fosfeno-web",
"version": "1.0.0",
"description": "Librerias del escenario de visuales de FOSFENO",
"private": true,
"dependencies": {
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
"codemirror": "^5.65.16",
"hydra-synth": "^1.3.29",
"qrcode-generator": "^1.4.4",
"socket.io-client": "^4.7.5"
}
}

201
web/panel/index.html Normal file
View file

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FOSFENO :: Panel</title>
<link rel="stylesheet" href="/panel/panel.css">
<link rel="stylesheet" href="/lib/codemirror/codemirror.css">
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
</head>
<body>
<header>
<h1>F O S F E N O</h1>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
</div>
<main>
<!-- Encendido -->
<section class="card center">
<div class="cardhead">
<span class="label">Visuales</span>
<button class="info" data-help="power">i</button>
</div>
<button id="power" class="power-btn">ON</button>
</section>
<!-- Selector de motor -->
<section class="card">
<div class="cardhead">
<span class="label">Motor de visuales</span>
<button class="info" data-help="engines">i</button>
</div>
<div class="engines">
<button class="engine" data-engine="projectm">
<strong>projectM</strong><small>nativo</small></button>
<button class="engine" data-engine="butterchurn">
<strong>Butter</strong><small>MilkDrop</small></button>
<button class="engine" data-engine="hydra">
<strong>Hydra</strong><small>codigo</small></button>
<button class="engine" data-engine="shaders">
<strong>Shaders</strong><small>GLSL</small></button>
<button class="engine" data-engine="mixer">
<strong>Mezcla</strong><small>camara</small></button>
</div>
</section>
<!-- Audio: tarjeta + BPM -->
<section class="card">
<div class="cardhead">
<span class="label">Audio</span>
<button class="info" data-help="audio">i</button>
</div>
<select id="audio-device"><option>Detectando entradas...</option></select>
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
<div class="row">
<span class="label">BPM detectado</span>
<span id="bpm" class="bpm">--</span>
</div>
<p class="hint">Si conectas el microfono o la camara con la Raspberry ya
encendida, pulsa este boton para que aparezcan.</p>
</section>
<!-- Sensibilidad -->
<section class="card">
<div class="cardhead">
<span class="label">Sensibilidad al audio:
<span id="sens-val" class="value">1.0</span></span>
<button class="info" data-help="sensibilidad">i</button>
</div>
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
</section>
<!-- Butterchurn -->
<section class="card" id="ctl-butterchurn" hidden>
<div class="cardhead">
<span class="label">Butterchurn</span>
<button class="info" data-help="butterchurn">i</button>
</div>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
<select id="bc-preset"><option>Cargando presets...</option></select>
<label class="check">
<input type="checkbox" id="bc-shuffle"> Cambio automatico de preset
</label>
<div class="seg">
<button id="bc-mode-seconds" class="segbtn">Por segundos</button>
<button id="bc-mode-beats" class="segbtn">Al compas (BPM)</button>
</div>
<div class="row">
<span class="label">Intervalo: <span id="bc-int-val">20</span>
<span id="bc-int-unit">s</span></span>
</div>
<input id="bc-interval" type="range" min="1" max="120" step="1" value="20">
<div class="row">
<span class="label">Transicion: <span id="bc-blend-val">2.7</span> s</span>
</div>
<input id="bc-blend" type="range" min="0" max="10" step="0.1" value="2.7">
</section>
<!-- Editor de codigo (Hydra / Shaders) -->
<section class="card" id="ctl-editor" hidden>
<div class="cardhead">
<span class="label" id="editor-title">Editor de codigo</span>
<button class="info" id="editor-info" data-help="hydra">i</button>
</div>
<select id="lib-select"><option>Libreria...</option></select>
<textarea id="code"></textarea>
<div class="row">
<button id="run-code" class="cmd accent">Ejecutar</button>
<button id="clear-code" class="cmd">Limpiar</button>
</div>
<p class="hint" id="editor-hint"></p>
</section>
<!-- Mezclador VJ -->
<section class="card" id="ctl-mixer" hidden>
<div class="cardhead">
<span class="label">Mezclador VJ</span>
<button class="info" data-help="mixer">i</button>
</div>
<div class="seg" id="mixer-source">
<button class="segbtn" data-src="cam">Camara</button>
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
</div>
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
<select id="mix-video"><option value="">-- sin video --</option></select>
<button id="mix-rescan" class="cmd">Actualizar lista de videos</button>
<div class="row">
<span class="label">Modo de mezcla</span>
<select id="mix-blend" class="inline">
<option value="blend">Fundido</option>
<option value="diff">Diferencia</option>
<option value="mult">Multiplicar</option>
<option value="add">Sumar</option>
<option value="layer">Capa</option>
</select>
</div>
<div id="mixer-sliders"></div>
<label class="check">
<input type="checkbox" id="mix-invert"> Invertir colores
</label>
<label class="check">
<input type="checkbox" id="mix-beat"> Pulso al ritmo
</label>
</section>
<!-- projectM -->
<section class="card" id="ctl-projectm" hidden>
<div class="cardhead">
<span class="label">projectM</span>
<button class="info" data-help="projectm">i</button>
</div>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
</section>
<!-- Estado -->
<section class="card">
<span class="label">Ahora suena</span>
<div id="now" class="now">-</div>
</section>
<!-- Sistema -->
<section class="card sys">
<button id="reboot" class="sysbtn">Reiniciar Pi</button>
<button id="shutdown" class="sysbtn danger">Apagar Pi</button>
</section>
<p id="net-foot" class="netfoot">FOSFENO</p>
</main>
<!-- Ventana de informacion -->
<div id="modal" class="modal" hidden>
<div class="modal-box">
<h2 id="modal-title">Informacion</h2>
<div id="modal-body"></div>
<button id="modal-close" class="cmd accent">Entendido</button>
</div>
</div>
<script src="/lib/socket.io.min.js"></script>
<script src="/lib/codemirror/codemirror.js"></script>
<script src="/lib/codemirror/javascript.js"></script>
<script src="/lib/codemirror/clike.js"></script>
<script src="/panel/panel.js"></script>
</body>
</html>

212
web/panel/panel.css Normal file
View file

@ -0,0 +1,212 @@
/* FOSFENO :: Panel de control
Tema verde acido y negro, con acentos en naranja neon. */
:root {
--bg: #060a06;
--card: #0d120c;
--line: #243016;
--green: #b4ff00; /* verde acido */
--green-d: #86bd00;
--orange: #ff7a00; /* naranja acido */
--orange-n: #ff9d2a; /* naranja neon */
--ink: #050705; /* negro: texto sobre superficies brillantes */
--txt: #cfe8ad; /* texto claro sobre fondo oscuro */
--dim: #6f8a52;
--red: #ff3b2f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
/* El atributo 'hidden' debe ocultar siempre, incluso sobre display:flex */
[hidden] { display: none !important; }
body {
background: var(--bg);
color: var(--txt);
font-family: "Segoe UI", Roboto, system-ui, sans-serif;
-webkit-tap-highlight-color: transparent;
padding-bottom: 48px;
}
header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px; border-bottom: 2px solid var(--green-d);
position: sticky; top: 0; background: var(--bg); z-index: 10;
}
header h1 {
font-size: 20px; font-weight: 800; letter-spacing: 0.32em;
color: var(--green); text-shadow: 0 0 14px rgba(180, 255, 0, 0.55);
}
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; }
.dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); }
main {
max-width: 560px; margin: 0 auto;
padding: 16px; display: flex; flex-direction: column; gap: 14px;
}
/* --- Tarjetas --- */
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: 16px; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
}
.card.center { align-items: center; }
.cardhead {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; width: 100%;
}
.label { color: var(--green); font-size: 13px; text-transform: uppercase;
letter-spacing: 0.09em; font-weight: 700; }
.value { color: var(--orange-n); font-weight: 800; }
.hint { color: var(--dim); font-size: 12px; line-height: 1.45; }
.row { display: flex; align-items: center; justify-content: space-between;
gap: 10px; }
.row .cmd { flex: 1; }
/* --- Boton de informacion (circular naranja) --- */
.info {
width: 28px; height: 28px; flex: none; border-radius: 50%;
background: var(--orange); color: var(--ink); border: none;
font-weight: 900; font-style: italic; font-size: 15px; cursor: pointer;
box-shadow: 0 0 10px rgba(255, 122, 0, 0.5);
}
.info:active { background: var(--orange-n); }
/* --- Boton de encendido (circular grande) --- */
.power-btn {
width: 128px; height: 128px; border-radius: 50%;
border: none; cursor: pointer; font-weight: 900; font-size: 26px;
letter-spacing: 0.05em;
background: var(--green); color: var(--ink);
box-shadow: 0 0 26px rgba(180, 255, 0, 0.55);
}
.power-btn.off {
background: var(--card); color: var(--dim);
border: 3px solid var(--line); box-shadow: none;
}
/* --- Selector de motor (botones circulares) --- */
.engines {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
}
.engine {
width: 96px; height: 96px; border-radius: 50%;
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; cursor: pointer;
background: var(--card); border: 3px solid var(--line); color: var(--txt);
}
.engine strong { font-size: 13px; }
.engine small { font-size: 9px; color: var(--dim); }
.engine.active {
background: var(--green); color: var(--ink); border-color: var(--orange);
box-shadow: 0 0 20px rgba(180, 255, 0, 0.6);
}
.engine.active small { color: var(--ink); }
/* --- Botones de comando --- */
.cmd, .sysbtn {
padding: 13px; border-radius: 11px; cursor: pointer;
background: #161d10; border: 1px solid var(--line); color: var(--txt);
font-weight: 700; font-size: 14px;
}
.cmd:active, .sysbtn:active { background: #202a16; }
.cmd.accent {
background: var(--green); color: var(--ink); border: none;
}
/* --- Selectores y sliders --- */
select {
width: 100%; padding: 12px; border-radius: 11px;
background: #161d10; color: var(--txt); border: 1px solid var(--line);
font-size: 14px;
}
select.inline { width: auto; padding: 8px 10px; }
input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
.check { display: flex; align-items: center; gap: 10px; font-size: 14px;
color: var(--txt); }
.check input { width: 22px; height: 22px; accent-color: var(--orange); }
/* --- Botones segmentados --- */
.seg { display: flex; gap: 6px; }
.segbtn {
flex: 1; padding: 11px 6px; border-radius: 10px; cursor: pointer;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
font-weight: 700; font-size: 13px;
}
.segbtn.active {
border-color: var(--orange); color: var(--orange-n);
box-shadow: 0 0 12px rgba(255, 122, 0, 0.35);
}
/* --- BPM --- */
.bpm {
font-size: 30px; font-weight: 900; color: var(--orange-n);
text-shadow: 0 0 14px rgba(255, 157, 42, 0.6);
font-variant-numeric: tabular-nums;
}
/* --- Sliders del mezclador --- */
.slider { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.slider .head {
display: flex; justify-content: space-between; font-size: 13px;
color: var(--dim);
}
.slider .head b { color: var(--green); font-weight: 800; }
/* --- Editor CodeMirror --- */
.CodeMirror {
height: 220px; border-radius: 11px; border: 1px solid var(--green-d);
font-size: 13px; font-family: "Fira Mono", Consolas, monospace;
}
.now { font-size: 15px; color: var(--orange-n); word-break: break-word; }
/* --- Sistema --- */
.sys { flex-direction: row; }
.sys .sysbtn { flex: 1; }
.sysbtn.danger { color: var(--red); border-color: var(--red); }
/* --- Banda de avisos --- */
.notif {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 14px; font-weight: 600;
position: sticky; top: 60px; z-index: 9;
}
.notif.info { background: var(--green); color: var(--ink); }
.notif.warn { background: var(--orange); color: var(--ink); }
.notif.error { background: var(--red); color: #fff; }
.notif span { flex: 1; }
.notif button {
background: transparent; border: none; color: inherit;
font-size: 22px; font-weight: 900; cursor: pointer; line-height: 1;
}
/* --- Ventana de informacion --- */
.modal {
position: fixed; inset: 0; z-index: 50;
background: rgba(3, 6, 3, 0.88);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.modal-box {
background: var(--card); border: 2px solid var(--green);
border-radius: 16px; padding: 22px; max-width: 460px; width: 100%;
max-height: 80vh; overflow-y: auto;
display: flex; flex-direction: column; gap: 14px;
box-shadow: 0 0 30px rgba(180, 255, 0, 0.3);
}
.modal-box h2 {
color: var(--green); font-size: 18px; letter-spacing: 0.04em;
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* Pie con la direccion del panel */
.netfoot {
text-align: center; color: var(--dim);
font-size: 12px; font-family: monospace; padding: 4px 0 8px;
}

409
web/panel/panel.js Normal file
View file

@ -0,0 +1,409 @@
/*
* FOSFENO :: Panel de control
* Conecta con el servidor, refleja el estado y envia ordenes.
* Incluye editor de codigo, mezclador VJ, ventana de ayuda y avisos.
*/
const socket = io();
let state = null;
let editorEngine = ""; // que motor tiene cargado el editor
let helpData = {}; // textos de ayuda
let lastNotifT = 0; // marca de tiempo del ultimo aviso mostrado
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// Sliders del mezclador VJ (se generan dinamicamente)
const MIXER_SLIDERS = [
{ key: "mix", label: "Mezcla A/B", min: 0, max: 1, step: 0.01 },
{ key: "hue", label: "Tono (hue)", min: 0, max: 1, step: 0.01 },
{ key: "saturate", label: "Saturacion", min: 0, max: 3, step: 0.05 },
{ key: "contrast", label: "Contraste", min: 0, max: 3, step: 0.05 },
{ key: "brightness", label: "Brillo", min: -0.5, max: 0.5, step: 0.02 },
{ key: "colorama", label: "Colorama", min: 0, max: 1, step: 0.01 },
{ key: "posterize", label: "Posterizar", min: 0, max: 8, step: 1 },
{ key: "pixelate", label: "Pixelado", min: 0, max: 1, step: 0.02 },
{ key: "kaleid", label: "Caleidoscopio", min: 0, max: 12, step: 1 },
{ key: "rotate", label: "Rotacion", min: -3, max: 3, step: 0.05 },
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01 },
];
// ==========================================================================
// Editor de codigo (CodeMirror)
// ==========================================================================
let editor = null;
if (typeof CodeMirror !== "undefined") {
editor = CodeMirror.fromTextArea($("#code"), {
lineNumbers: true, theme: "material-darker",
mode: "javascript", lineWrapping: true,
});
}
// ==========================================================================
// Librerias de codigo y de ayuda
// ==========================================================================
let hydraLib = [];
let shaderLib = [];
async function loadData() {
try {
const sketches = await fetch("/data/hydra-sketches.json").then((r) => r.json());
const snippets = await fetch("/data/hydra-snippets.json").then((r) => r.json());
const shaders = await fetch("/data/shaders.json").then((r) => r.json());
hydraLib = [];
for (const [id, v] of Object.entries(sketches)) {
hydraLib.push({ id: "s:" + id, name: "* " + (v.name || id), code: v.code });
}
for (const [id, v] of Object.entries(snippets.snippets || {})) {
hydraLib.push({ id: "x:" + id, name: v.name || id, code: v.code });
}
shaderLib = Object.entries(shaders).map(
([id, v]) => ({ id: id, name: v.name || id, code: v.code }));
} catch (err) {
console.error("[FOSFENO] No se pudieron cargar las librerias:", err);
}
try {
const ayuda = await fetch("/data/ayuda.json").then((r) => r.json());
helpData = ayuda.ayuda || {};
} catch (err) {
console.error("[FOSFENO] No se pudo cargar la ayuda:", err);
}
// Si el estado llego antes que las librerias, repinta para que aparezcan
if (state) { editorEngine = ""; render(); }
}
function fillLibrarySelect(lib) {
const sel = $("#lib-select");
sel.innerHTML = '<option value="">-- Cargar de la libreria --</option>';
lib.forEach((it, i) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = it.name;
sel.appendChild(opt);
});
}
// ==========================================================================
// Ventana de informacion
// ==========================================================================
function openHelp(key) {
const h = helpData[key];
if (!h) return;
$("#modal-title").textContent = h.title || "Informacion";
const body = $("#modal-body");
body.innerHTML = "";
(h.body || []).forEach((par) => {
const p = document.createElement("p");
p.textContent = par;
body.appendChild(p);
});
$("#modal").hidden = false;
}
function closeHelp() { $("#modal").hidden = true; }
// ==========================================================================
// Avisos (banda superior)
// ==========================================================================
let notifTimer = null;
function showNotif(entry) {
if (!entry || !entry.message) return;
const n = $("#notif");
n.className = "notif " + (entry.level || "info");
$("#notif-msg").textContent = entry.message;
n.hidden = false;
clearTimeout(notifTimer);
if (entry.level === "info") {
notifTimer = setTimeout(() => { n.hidden = true; }, 6000);
}
if (entry.t) lastNotifT = entry.t;
}
// ==========================================================================
// Sliders del mezclador
// ==========================================================================
function fmt(v, step) {
return step >= 1 ? String(Math.round(v)) : Number(v).toFixed(2);
}
function buildMixerSliders() {
const cont = $("#mixer-sliders");
MIXER_SLIDERS.forEach((cfg) => {
const wrap = document.createElement("div");
wrap.className = "slider";
wrap.innerHTML =
`<div class="head"><span>${cfg.label}</span>` +
`<b id="mv-${cfg.key}">-</b></div>`;
const input = document.createElement("input");
input.type = "range";
input.min = cfg.min; input.max = cfg.max; input.step = cfg.step;
input.id = "ms-" + cfg.key;
input.addEventListener("input", () => {
$("#mv-" + cfg.key).textContent = fmt(input.value, cfg.step);
});
input.addEventListener("change", () => {
const patch = {};
patch[cfg.key] = parseFloat(input.value);
socket.emit("update_settings", { engine: "mixer", patch: patch });
});
wrap.appendChild(input);
cont.appendChild(wrap);
});
}
// ==========================================================================
// Render
// ==========================================================================
function render() {
if (!state) return;
$("#conn").className = "dot on";
const power = $("#power");
power.textContent = state.power ? "ON" : "OFF";
power.classList.toggle("off", !state.power);
$$(".engine").forEach((b) =>
b.classList.toggle("active", b.dataset.engine === state.engine));
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
if (state.audio.device) $("#audio-device").value = state.audio.device;
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
$("#sens").value = state.sensitivity;
$("#sens-val").textContent = Number(state.sensitivity).toFixed(1);
$("#ctl-butterchurn").hidden = state.engine !== "butterchurn";
$("#ctl-editor").hidden = !(state.engine === "hydra" || state.engine === "shaders");
$("#ctl-mixer").hidden = state.engine !== "mixer";
$("#ctl-projectm").hidden = state.engine !== "projectm";
renderButterchurn();
renderEditor();
renderMixer();
$("#now").textContent = state.status.label || "-";
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
$("#net-foot").textContent = "Panel: http://" +
(net.hostname || "fosfeno") + ".local" + port + "/" +
(net.ip ? " (" + net.ip + ")" : "");
// Aviso pendiente recibido antes de abrir el panel
const notes = state.notifications || [];
if (notes.length) {
const last = notes[notes.length - 1];
if (last.t !== lastNotifT) showNotif(last);
}
}
function fillSelectOnce(sel, items, valFn, txtFn) {
items = items || [];
if (sel.dataset.count == items.length) return;
sel.dataset.count = items.length;
let keep = "";
if (sel.id === "mix-video") {
keep = '<option value="">-- sin video --</option>';
} else if (sel.id === "mix-camera" && items.length === 0) {
keep = '<option value="0">Camara por defecto</option>';
}
sel.innerHTML = keep;
items.forEach((it) => {
const opt = document.createElement("option");
opt.value = valFn(it);
opt.textContent = txtFn(it);
sel.appendChild(opt);
});
}
function renderButterchurn() {
if (state.engine !== "butterchurn") return;
const b = state.butterchurn;
fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets,
(n) => n, (n) => n);
if (b.preset) $("#bc-preset").value = b.preset;
$("#bc-shuffle").checked = b.shuffle;
$("#bc-mode-seconds").classList.toggle("active", b.intervalMode === "seconds");
$("#bc-mode-beats").classList.toggle("active", b.intervalMode === "beats");
$("#bc-interval").value = b.interval;
$("#bc-int-val").textContent = b.interval;
$("#bc-int-unit").textContent = b.intervalMode === "beats" ? "comp." : "s";
$("#bc-blend").value = b.blendTime;
$("#bc-blend-val").textContent = Number(b.blendTime).toFixed(1);
}
function renderEditor() {
if (state.engine !== "hydra" && state.engine !== "shaders") return;
$("#editor-info").dataset.help = state.engine;
if (state.engine !== editorEngine) {
editorEngine = state.engine;
const isHydra = state.engine === "hydra";
$("#editor-title").textContent = isHydra
? "Editor Hydra (JavaScript)" : "Editor de shaders (GLSL)";
$("#editor-hint").textContent = isHydra
? "Variables: time, a.fft[0..4], bpm. Escribe o pega codigo Hydra."
: "Uniforms: u_time, u_bass, u_mid, u_treble, u_bpm, u_beat, u_fft.";
fillLibrarySelect(isHydra ? hydraLib : shaderLib);
if (editor) {
editor.setOption("mode", isHydra ? "javascript" : "text/x-csrc");
editor.setValue(state[state.engine].code || "");
setTimeout(() => editor.refresh(), 30);
}
}
}
function renderMixer() {
if (state.engine !== "mixer") return;
const m = state.mixer;
$$("#mixer-source .segbtn").forEach((b) =>
b.classList.toggle("active", b.dataset.src === m.source));
$("#mix-cam").checked = m.camOn;
fillSelectOnce($("#mix-camera"), state.meta.cameraDevices,
(d) => d.id, (d) => d.name);
$("#mix-camera").value = m.cameraId;
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
(o) => o.v, (o) => o.v);
$("#mix-video").value = m.video || "";
$("#mix-blend").value = m.blendMode;
$("#mix-invert").checked = m.invert;
$("#mix-beat").checked = m.beatPulse;
MIXER_SLIDERS.forEach((cfg) => {
const input = $("#ms-" + cfg.key);
if (input && document.activeElement !== input) {
input.value = m[cfg.key];
$("#mv-" + cfg.key).textContent = fmt(m[cfg.key], cfg.step);
}
});
}
// ==========================================================================
// Eventos de la interfaz
// ==========================================================================
$("#power").addEventListener("click", () =>
socket.emit("set_power", { on: !state.power }));
$$(".engine").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("set_engine", { engine: b.dataset.engine })));
$$(".info").forEach((b) =>
b.addEventListener("click", () => openHelp(b.dataset.help)));
$("#modal-close").addEventListener("click", closeHelp);
$("#modal").addEventListener("click", (e) => {
if (e.target.id === "modal") closeHelp();
});
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
$("#audio-device").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "audio", patch: { device: e.target.value } }));
$("#sens").addEventListener("input", (e) =>
$("#sens-val").textContent = Number(e.target.value).toFixed(1));
$("#sens").addEventListener("change", (e) =>
socket.emit("set_sensitivity", { value: parseFloat(e.target.value) }));
// --- Butterchurn ---
$("#bc-preset").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: e.target.value } }));
$("#bc-shuffle").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
$("#bc-mode-seconds").addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { intervalMode: "seconds" } }));
$("#bc-mode-beats").addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { intervalMode: "beats" } }));
$("#bc-interval").addEventListener("input", (e) =>
$("#bc-int-val").textContent = e.target.value);
$("#bc-interval").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { interval: parseInt(e.target.value, 10) } }));
$("#bc-blend").addEventListener("input", (e) =>
$("#bc-blend-val").textContent = Number(e.target.value).toFixed(1));
$("#bc-blend").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "butterchurn", patch: { blendTime: parseFloat(e.target.value) } }));
// --- Editor de codigo ---
$("#run-code").addEventListener("click", () => {
if (!editor || !editorEngine) return;
socket.emit("run_code",
{ engine: editorEngine, code: editor.getValue(), label: "codigo personalizado" });
});
$("#clear-code").addEventListener("click", () => editor && editor.setValue(""));
$("#lib-select").addEventListener("change", (e) => {
const lib = editorEngine === "hydra" ? hydraLib : shaderLib;
const item = lib[parseInt(e.target.value, 10)];
if (!item || !editor) return;
editor.setValue(item.code);
socket.emit("run_code",
{ engine: editorEngine, code: item.code, label: item.name });
});
// --- Mezclador ---
$$("#mixer-source .segbtn").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "mixer", patch: { source: b.dataset.src } })));
$("#mix-cam").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { camOn: e.target.checked } }));
$("#mix-camera").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { cameraId: parseInt(e.target.value, 10) } }));
$("#dev-rescan").addEventListener("click", () =>
socket.emit("rescan_devices"));
$("#mix-video").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { video: e.target.value } }));
$("#mix-blend").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { blendMode: e.target.value } }));
$("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos"));
$("#mix-invert").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { invert: e.target.checked } }));
$("#mix-beat").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
// --- Comandos siguiente/anterior ---
$$(".cmd[data-action]").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("engine_command", { action: b.dataset.action })));
// --- Sistema ---
$("#reboot").addEventListener("click", () => {
if (confirm("Reiniciar la Raspberry Pi?")) socket.emit("system", { action: "reboot" });
});
$("#shutdown").addEventListener("click", () => {
if (confirm("Apagar la Raspberry Pi?")) socket.emit("system", { action: "shutdown" });
});
// ==========================================================================
// WebSocket
// ==========================================================================
// Al conectar, el panel se presenta para que el proyector deje de mostrar
// la pantalla del codigo QR.
socket.on("connect", () => socket.emit("hello", { role: "panel" }));
socket.on("state", (next) => { state = next; render(); });
socket.on("status", (s) => {
if (!state) return;
state.status.label = s.label;
state.audio.bpm = s.bpm;
$("#now").textContent = s.label || "-";
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
});
socket.on("notify", (entry) => showNotif(entry));
socket.on("disconnect", () => { $("#conn").className = "dot off"; });
// ==========================================================================
// Arranque
// ==========================================================================
buildMixerSliders();
loadData();

69
web/stage/index.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FOSFENO :: Escenario</title>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
background: #000; overflow: hidden; cursor: none;
}
canvas {
position: absolute; top: 0; left: 0;
width: 100vw; height: 100vh; display: none;
}
canvas.visible { display: block; }
#msg {
position: absolute; top: 50%; left: 0; right: 0;
transform: translateY(-50%); text-align: center;
color: #2a2a35; font-family: monospace; font-size: 2vw;
letter-spacing: 0.3em;
}
/* Pantalla de conexion: se muestra hasta que alguien abre el panel */
#info {
position: absolute; inset: 0; display: none;
flex-direction: column; align-items: center; justify-content: center;
gap: 2.2vh; background: #050805;
font-family: "Segoe UI", system-ui, sans-serif;
}
.info-title {
color: #b4ff00; font-size: 5vh; font-weight: 800; letter-spacing: 0.4em;
text-shadow: 0 0 24px rgba(180, 255, 0, 0.6);
}
.info-text { color: #cfe8ad; font-size: 2.8vh; }
.info-url {
color: #ff9d2a; font-size: 4.4vh; font-weight: 800; font-family: monospace;
text-shadow: 0 0 18px rgba(255, 157, 42, 0.5);
}
.info-qr {
width: 34vh; height: 34vh; background: #fff;
padding: 1.6vh; border-radius: 1.4vh; image-rendering: pixelated;
}
.info-sub { color: #6f8a52; font-size: 2.3vh; font-family: monospace; }
</style>
</head>
<body>
<div id="msg">FOSFENO</div>
<!-- Pantalla de conexion (QR + direccion del panel) -->
<div id="info">
<div class="info-title">F O S F E N O</div>
<div class="info-text">Escanea el codigo con el movil, o abre la direccion:</div>
<div class="info-url" id="info-url">buscando direccion...</div>
<img id="info-qr" class="info-qr" alt="codigo QR del panel">
<div class="info-sub" id="info-sub"></div>
</div>
<canvas id="butterchurn"></canvas>
<canvas id="hydra"></canvas>
<canvas id="shaders"></canvas>
<script src="/lib/socket.io.min.js"></script>
<script src="/lib/qrcode.js"></script>
<script src="/lib/butterchurn.min.js"></script>
<script src="/lib/butterchurn-presets.min.js"></script>
<script src="/lib/hydra-synth.js"></script>
<script src="/stage/stage.js"></script>
</body>
</html>

602
web/stage/stage.js Normal file
View file

@ -0,0 +1,602 @@
/*
* FOSFENO :: Escenario
* Renderiza las visuales en el proyector. Motores:
* - butterchurn : presets MilkDrop (WebGL)
* - hydra : codigo Hydra en vivo (editor / libreria)
* - shaders : shaders GLSL en vivo (editor)
* - mixer : mezclador VJ (camara + video + efectos, via Hydra)
* - projectm : nativo (esta pagina se queda en negro y se superpone)
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
*/
const socket = io();
const msgEl = document.getElementById("msg");
const bcCanvas = document.getElementById("butterchurn");
const hydraCanvas = document.getElementById("hydra");
const shaderCanvas = document.getElementById("shaders");
let audioCtx, gainNode, analyser, micSource, micStream;
let currentDevice = "";
let state = null;
let lastQrUrl = ""; // ultima URL para la que se genero el QR
// Estado del detector de ritmo, compartido por todos los motores
const fosBeat = { bpm: 0, phase: 0, level: 0, isBeat: false };
// --- Butterchurn ---
let bcViz = null, bcRAF = null, bcTimer = null;
let bcPresets = {}, bcNames = [], bcIndex = 0, bcCurrent = "";
let bcBeatCount = 0;
// --- Hydra / Mixer ---
let hydra = null, hydraCode = "", mixerSig = "";
let mixerVideo = "", mixerCam = null, mixerCamId = null;
// --- Shaders ---
let shaderEngine = null, shaderCode = "";
/* Comunica un aviso al servidor para que aparezca en el panel de control.
Asi el usuario siempre ve que ha pasado, no se queda el sistema mudo. */
function report(level, message) {
if (level === "error") console.error("[FOSFENO]", message);
else console.warn("[FOSFENO]", message);
try { socket.emit("stage_notify", { level: level, message: message }); }
catch (e) { /* sin conexion */ }
}
// ==========================================================================
// Detector de BPM (analisis de energia de graves en tiempo real)
// ==========================================================================
class BeatDetector {
constructor(analyserNode) {
this.analyser = analyserNode;
this.freq = new Uint8Array(analyserNode.frequencyBinCount);
this.history = [];
this.HISTORY = 55;
this.lastBeat = 0;
this.intervals = [];
this.bpm = 0;
}
update(now) {
this.analyser.getByteFrequencyData(this.freq);
const n = this.freq.length;
const hi = Math.max(2, Math.floor(n * 0.08));
let e = 0;
for (let i = 0; i < hi; i++) e += this.freq[i];
e /= hi;
this.history.push(e);
if (this.history.length > this.HISTORY) this.history.shift();
const avg = this.history.reduce((s, v) => s + v, 0) / this.history.length;
let isBeat = false;
const minGap = 60000 / 210; // tope de 210 BPM
if (e > avg * 1.35 && e > 24 && now - this.lastBeat > minGap) {
if (this.lastBeat > 0) {
const dt = now - this.lastBeat;
if (dt > 200 && dt < 2000) {
this.intervals.push(dt);
if (this.intervals.length > 40) this.intervals.shift();
this._estimate();
}
}
this.lastBeat = now;
isBeat = true;
}
fosBeat.level = e / 255;
fosBeat.bpm = this.bpm;
fosBeat.isBeat = isBeat;
if (this.bpm > 0) {
const beatMs = 60000 / this.bpm;
fosBeat.phase = ((now - this.lastBeat) % beatMs) / beatMs;
}
return isBeat;
}
_estimate() {
if (this.intervals.length < 6) return;
// Agrupa intervalos parecidos (tolerancia 8%) y elige el grupo mayoritario
const groups = [];
for (const iv of this.intervals) {
const g = groups.find((x) => Math.abs(x.mean - iv) / x.mean < 0.08);
if (g) { g.sum += iv; g.count++; g.mean = g.sum / g.count; }
else groups.push({ sum: iv, count: 1, mean: iv });
}
groups.sort((a, b) => b.count - a.count);
let bpm = 60000 / groups[0].mean;
while (bpm < 70) bpm *= 2;
while (bpm > 180) bpm /= 2;
this.bpm = Math.round(bpm);
}
}
let beatDetector = null;
// ==========================================================================
// Motor de shaders GLSL (estilo Shadertoy, reactivo al audio y al BPM)
// Uniforms: u_resolution, u_time, u_bass, u_mid, u_treble, u_level,
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
// ==========================================================================
class ShaderEngine {
constructor(canvas, analyserNode) {
this.canvas = canvas;
this.analyser = analyserNode;
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
this.raf = null;
this.program = null;
this.loc = {};
this.startTime = performance.now();
this.fftData = new Uint8Array(analyserNode.frequencyBinCount);
this._initQuad();
this._initFFTTexture();
}
_initQuad() {
const gl = this.gl;
this.quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
}
_initFFTTexture() {
const gl = this.gl;
this.fftTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
}
_compile(type, src) {
const gl = this.gl;
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
report("error", "El shader no compila. Revisa el codigo GLSL "
+ "(el detalle tecnico esta en la consola del navegador).");
gl.deleteShader(sh);
return null;
}
return sh;
}
load(fragBody) {
const gl = this.gl;
const vs = "attribute vec2 p; void main(){ gl_Position = vec4(p,0.0,1.0); }";
const header =
"precision highp float;\n" +
"uniform vec2 u_resolution;\n" +
"uniform float u_time;\n" +
"uniform float u_bass;\n" +
"uniform float u_mid;\n" +
"uniform float u_treble;\n" +
"uniform float u_level;\n" +
"uniform float u_bpm;\n" +
"uniform float u_beat;\n" +
"uniform sampler2D u_fft;\n";
const vsh = this._compile(gl.VERTEX_SHADER, vs);
const fsh = this._compile(gl.FRAGMENT_SHADER, header + fragBody);
if (!vsh || !fsh) return false;
const prog = gl.createProgram();
gl.attachShader(prog, vsh);
gl.attachShader(prog, fsh);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error("[FOSFENO] link:", gl.getProgramInfoLog(prog));
return false;
}
if (this.program) gl.deleteProgram(this.program);
this.program = prog;
const u = (name) => gl.getUniformLocation(prog, name);
this.loc = {
p: gl.getAttribLocation(prog, "p"),
res: u("u_resolution"), time: u("u_time"),
bass: u("u_bass"), mid: u("u_mid"), treble: u("u_treble"),
level: u("u_level"), bpm: u("u_bpm"), beat: u("u_beat"), fft: u("u_fft"),
};
return true;
}
_bands() {
this.analyser.getByteFrequencyData(this.fftData);
const n = this.fftData.length;
const avg = (lo, hi) => {
let s = 0;
for (let i = lo; i < hi; i++) s += this.fftData[i];
return s / Math.max(1, (hi - lo) * 255);
};
return [
avg(1, Math.floor(n * 0.06)),
avg(Math.floor(n * 0.06), Math.floor(n * 0.30)),
avg(Math.floor(n * 0.30), Math.floor(n * 0.75)),
avg(1, n),
];
}
_frame() {
const gl = this.gl;
const w = this.canvas.width = window.innerWidth;
const h = this.canvas.height = window.innerHeight;
gl.viewport(0, 0, w, h);
if (this.program) {
const b = this._bands();
gl.useProgram(this.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
this.fftData.length, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, this.fftData);
gl.uniform1i(this.loc.fft, 0);
gl.uniform2f(this.loc.res, w, h);
gl.uniform1f(this.loc.time, (performance.now() - this.startTime) / 1000);
gl.uniform1f(this.loc.bass, b[0]);
gl.uniform1f(this.loc.mid, b[1]);
gl.uniform1f(this.loc.treble, b[2]);
gl.uniform1f(this.loc.level, b[3]);
gl.uniform1f(this.loc.bpm, fosBeat.bpm);
gl.uniform1f(this.loc.beat, fosBeat.phase);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.enableVertexAttribArray(this.loc.p);
gl.vertexAttribPointer(this.loc.p, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
this.raf = requestAnimationFrame(() => this._frame());
}
start() { if (!this.raf) this._frame(); }
stop() { if (this.raf) cancelAnimationFrame(this.raf); this.raf = null; }
}
// ==========================================================================
// Audio: captura del microfono + seleccion de tarjeta
// ==========================================================================
async function acquireMic(deviceId) {
if (micStream) micStream.getTracks().forEach((t) => t.stop());
const constraints = {
audio: {
echoCancellation: false, noiseSuppression: false, autoGainControl: false,
},
};
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
micStream = await navigator.mediaDevices.getUserMedia(constraints);
if (micSource) micSource.disconnect();
micSource = audioCtx.createMediaStreamSource(micStream);
micSource.connect(gainNode);
currentDevice = deviceId || "";
}
async function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
await audioCtx.resume();
gainNode = audioCtx.createGain();
gainNode.gain.value = 1.0;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.8;
gainNode.connect(analyser);
await acquireMic(null);
beatDetector = new BeatDetector(analyser);
await enumerateInputs();
}
/* Lista los microfonos y las camaras y los envia al panel. Se llama al
arrancar y cada vez que el panel pide volver a buscar dispositivos. */
async function enumerateInputs() {
const devs = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devs.filter((d) => d.kind === "audioinput")
.map((d, i) => ({ id: d.deviceId,
name: d.label || ("Entrada de audio " + (i + 1)) }));
const cameraInputs = devs.filter((d) => d.kind === "videoinput")
.map((d, i) => ({ id: i, name: d.label || ("Camara " + (i + 1)) }));
socket.emit("stage_meta", {
audioDevices: audioInputs, cameraDevices: cameraInputs,
});
}
// Bucle permanente del detector de ritmo (independiente del motor activo)
let lastBpmSent = 0;
function beatLoop() {
const now = performance.now();
const isBeat = beatDetector ? beatDetector.update(now) : false;
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
// Cambio de preset de Butterchurn sincronizado al compas
if (isBeat && state && state.engine === "butterchurn" && state.power
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
bcBeatCount++;
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
bcBeatCount = 0;
bcStep(1);
}
}
if (now - lastBpmSent > 2000) {
lastBpmSent = now;
socket.emit("stage_status", { bpm: fosBeat.bpm });
}
requestAnimationFrame(beatLoop);
}
// ==========================================================================
// Butterchurn
// ==========================================================================
function initButterchurn() {
const bch = window.butterchurn.default || window.butterchurn;
bcViz = bch.createVisualizer(audioCtx, bcCanvas, {
width: window.innerWidth, height: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1,
});
bcViz.connectAudio(gainNode);
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
bcPresets = bp.getPresets();
bcNames = Object.keys(bcPresets);
bcIndex = Math.floor(Math.random() * bcNames.length);
socket.emit("stage_meta", { butterchurnPresets: bcNames });
}
function loadButterchurnPreset(name) {
if (!bcPresets[name]) return;
bcCurrent = name;
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", { label: name });
}
function bcStep(delta) {
bcIndex = (bcIndex + delta + bcNames.length) % bcNames.length;
loadButterchurnPreset(bcNames[bcIndex]);
}
function bcLoop() {
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
bcViz.render();
bcRAF = requestAnimationFrame(bcLoop);
}
function startButterchurn() { if (!bcRAF) bcLoop(); }
function stopButterchurn() {
if (bcRAF) cancelAnimationFrame(bcRAF);
bcRAF = null;
clearInterval(bcTimer);
bcTimer = null;
}
function refreshShuffle() {
clearInterval(bcTimer);
bcTimer = null;
bcBeatCount = 0;
if (state && state.engine === "butterchurn" && state.power
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1),
(state.butterchurn.interval || 20) * 1000);
}
}
// ==========================================================================
// Hydra (codigo en vivo) + Mixer (mezclador VJ)
// ==========================================================================
function initHydra() {
hydra = new Hydra({
canvas: hydraCanvas, detectAudio: true, makeGlobal: true,
width: window.innerWidth, height: window.innerHeight,
});
a.setSmooth(0.85);
a.setBins(5);
}
function runHydraCode(code) {
if (!code) return;
hydraCode = code;
try {
// eslint-disable-next-line no-eval
eval(code);
} catch (err) {
report("error", "El codigo de Hydra ha fallado: " + err.message);
}
}
/* Construye una cadena de codigo Hydra a partir de los ajustes del mezclador. */
function buildMixerCode(m) {
let base;
if (m.source === "video") base = "src(s1)";
else if (m.source === "mix") base = `src(s0).${m.blendMode}(src(s1), ${m.mix})`;
else base = "src(s0)";
let c = base;
if (m.kaleid > 1) c += `.kaleid(${m.kaleid})`;
if (m.rotate) c += `.rotate(${m.rotate})`;
if (m.pixelate > 0) {
const px = Math.round(220 - m.pixelate * 210);
c += `.pixelate(${px}, ${px})`;
}
if (m.hue) c += `.hue(${m.hue})`;
if (m.saturate !== 1) c += `.saturate(${m.saturate})`;
if (m.contrast !== 1) c += `.contrast(${m.contrast})`;
if (m.brightness) c += `.brightness(${m.brightness})`;
if (m.colorama > 0) c += `.colorama(${m.colorama})`;
if (m.posterize > 0) c += `.posterize(${m.posterize}, 0.5)`;
if (m.invert) c += ".invert()";
if (m.beatPulse) c += ".scale(() => 1.0 + a.fft[0]*0.3)";
if (m.feedback > 0) c += `.blend(o0, ${m.feedback})`;
c += ".out(o0)";
return c;
}
/* Inicializa las fuentes (camara s0, video s1) solo cuando cambian. */
function ensureMixerSources(m) {
const wantCam = m.camOn && (m.source === "cam" || m.source === "mix");
if (wantCam && (mixerCam !== true || mixerCamId !== m.cameraId)) {
try {
s0.initCam(m.cameraId);
mixerCam = true;
mixerCamId = m.cameraId;
} catch (e) {
report("error", "No se pudo activar la camara. Comprueba que la "
+ "webcam USB esta conectada, o elige otra camara en el panel.");
}
} else if (!wantCam) {
mixerCam = false;
}
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
if (wantVideo && m.video !== mixerVideo) {
try { s1.initVideo("/data/videos/" + encodeURIComponent(m.video)); }
catch (e) {
report("error", "No se pudo cargar el video '" + m.video + "'.");
}
mixerVideo = m.video;
}
}
function applyMixer(m) {
ensureMixerSources(m);
const sig = JSON.stringify(m);
if (sig !== mixerSig) {
mixerSig = sig;
runHydraCode(buildMixerCode(m));
socket.emit("stage_status", { label: "Mezclador VJ" });
}
}
// ==========================================================================
// Conmutacion de motores
// ==========================================================================
function show(canvas) {
bcCanvas.classList.toggle("visible", canvas === bcCanvas);
hydraCanvas.classList.toggle("visible", canvas === hydraCanvas);
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
document.getElementById("info").style.display = "none";
msgEl.style.display = canvas ? "none" : "block";
}
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
proyector. Se ve al arrancar (hasta que alguien abre el panel) y cuando las
visuales estan apagadas, para que conectarse sea inmediato. */
function renderInfoScreen() {
show(null);
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/";
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/";
document.getElementById("info-url").textContent = ipUrl;
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
lastQrUrl = ipUrl;
try {
const qr = qrcode(0, "M");
qr.addData(ipUrl);
qr.make();
document.getElementById("info-qr").src = qr.createDataURL(7, 8);
} catch (e) { /* sin QR: queda la direccion en texto */ }
}
document.getElementById("info").style.display = "flex";
}
function applyState(next) {
const prevDevice = currentDevice;
state = next;
if (gainNode) gainNode.gain.value = state.sensitivity;
// Cambio de tarjeta de audio
if (state.audio.device && state.audio.device !== prevDevice) {
acquireMic(state.audio.device).catch(() =>
report("error", "No se pudo cambiar a la tarjeta de audio elegida."));
}
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
// el proyector muestra como conectarse (direccion + codigo QR).
const showConnect = !(state.network && state.network.panelSeen) || !state.power;
if (showConnect || state.engine === "projectm") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
if (hydra) hydra.hush();
mixerSig = "";
if (showConnect) renderInfoScreen();
else show(null);
return;
}
if (state.engine === "butterchurn") {
if (hydra) hydra.hush();
if (shaderEngine) shaderEngine.stop();
mixerSig = "";
show(bcCanvas);
startButterchurn();
const wanted = state.butterchurn.preset;
if (wanted && wanted !== bcCurrent) loadButterchurnPreset(wanted);
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
refreshShuffle();
} else if (state.engine === "hydra") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
mixerSig = "";
show(hydraCanvas);
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
} else if (state.engine === "shaders") {
stopButterchurn();
if (hydra) hydra.hush();
mixerSig = "";
show(shaderCanvas);
if (shaderEngine && state.shaders.code !== shaderCode) {
if (shaderEngine.load(state.shaders.code)) shaderCode = state.shaders.code;
}
if (shaderEngine) shaderEngine.start();
} else if (state.engine === "mixer") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
show(hydraCanvas);
applyMixer(state.mixer);
}
}
// ==========================================================================
// WebSocket
// ==========================================================================
socket.on("state", (next) => applyState(next));
socket.on("stage_command", (cmd) => {
if (!state) return;
if (state.engine === "butterchurn") {
if (cmd.action === "next") bcStep(1);
else if (cmd.action === "prev") bcStep(-1);
}
});
socket.on("stage_rescan", () => {
enumerateInputs().catch((e) =>
report("warn", "No se pudieron volver a leer los dispositivos."));
});
window.addEventListener("resize", () => {
if (hydra) hydra.setResolution(window.innerWidth, window.innerHeight);
});
// ==========================================================================
// Arranque
// ==========================================================================
async function boot() {
try {
if (typeof Hydra === "undefined" || typeof butterchurn === "undefined") {
report("error", "Faltan librerias de visuales. Ejecuta install.sh "
+ "de nuevo en la Raspberry.");
msgEl.textContent = "ERROR: faltan librerias";
return;
}
await initAudio();
initButterchurn();
initHydra();
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
beatLoop();
msgEl.textContent = "FOSFENO listo";
} catch (err) {
report("error", "Fallo al arrancar el escenario: " + err.message
+ ". Si menciona el microfono, comprueba que esta conectado.");
msgEl.textContent = "ERROR: " + err.message;
}
}
boot();