commit 30a09fdee6aa4b00bde1c2e072d74dabab1704f6 Author: hacklab Date: Fri May 22 14:18:19 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93ba9b5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6b3cb8 --- /dev/null +++ b/README.md @@ -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 /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.* diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e2a65d4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +flask-socketio>=5.3 +simple-websocket>=1.0 diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..71d9b69 --- /dev/null +++ b/backend/server.py @@ -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:///) + - sirve el ESCENARIO (http:///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/") +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/") +def stage_files(fname): + return send_from_directory(str(WEB / "stage"), fname) + + +@app.route("/lib/") +def lib_files(fname): + return send_from_directory(str(WEB / "lib"), fname) + + +@app.route("/data/") +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() diff --git a/config.json b/config.json new file mode 100644 index 0000000..5d45f03 --- /dev/null +++ b/config.json @@ -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://.local/" + }, + "defaults": { + "engine": "butterchurn", + "sensitivity": 1.0, + "hydraSketch": "kaleido", + "shader": "tunel" + } +} diff --git a/data/ayuda.json b/data/ayuda.json new file mode 100644 index 0000000..5dc20b3 --- /dev/null +++ b/data/ayuda.json @@ -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." + ] + } + } +} diff --git a/data/hydra-sketches.json b/data/hydra-sketches.json new file mode 100644 index 0000000..bf12669 --- /dev/null +++ b/data/hydra-sketches.json @@ -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)" + } +} diff --git a/data/hydra-snippets.json b/data/hydra-snippets.json new file mode 100644 index 0000000..686203b --- /dev/null +++ b/data/hydra-snippets.json @@ -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)" + } + } +} diff --git a/data/shaders.json b/data/shaders.json new file mode 100644 index 0000000..e03cab8 --- /dev/null +++ b/data/shaders.json @@ -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}" + } +} diff --git a/data/videos/README.txt b/data/videos/README.txt new file mode 100644 index 0000000..494ed56 --- /dev/null +++ b/data/videos/README.txt @@ -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). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d22d43f --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 0000000..b9c15df --- /dev/null +++ b/docs/assets/README.md @@ -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/`. diff --git a/docs/assets/banner.svg b/docs/assets/banner.svg new file mode 100644 index 0000000..ee350b8 --- /dev/null +++ b/docs/assets/banner.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FOSFENO + + motor de visuales audio-reactivas para raspberry pi + + + diff --git a/docs/conexion.md b/docs/conexion.md new file mode 100644 index 0000000..263c77c --- /dev/null +++ b/docs/conexion.md @@ -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). diff --git a/docs/instalacion.md b/docs/instalacion.md new file mode 100644 index 0000000..510728d --- /dev/null +++ b/docs/instalacion.md @@ -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 /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. diff --git a/docs/problemas.md b/docs/problemas.md new file mode 100644 index 0000000..f493763 --- /dev/null +++ b/docs/problemas.md @@ -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. diff --git a/docs/requisitos.md b/docs/requisitos.md new file mode 100644 index 0000000..70623fc --- /dev/null +++ b/docs/requisitos.md @@ -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. diff --git a/docs/uso.md b/docs/uso.md new file mode 100644 index 0000000..eba3c6b --- /dev/null +++ b/docs/uso.md @@ -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. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..dad0afa --- /dev/null +++ b/install.sh @@ -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 + 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://.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 < 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 diff --git a/scripts/build-projectm.sh b/scripts/build-projectm.sh new file mode 100755 index 0000000..3f1a09f --- /dev/null +++ b/scripts/build-projectm.sh @@ -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." diff --git a/scripts/fosfeno-autostart.desktop b/scripts/fosfeno-autostart.desktop new file mode 100644 index 0000000..62e36c6 --- /dev/null +++ b/scripts/fosfeno-autostart.desktop @@ -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 diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..814d599 --- /dev/null +++ b/scripts/lib.sh @@ -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 +# 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; } diff --git a/scripts/start-fosfeno.sh b/scripts/start-fosfeno.sh new file mode 100755 index 0000000..3ceb6cb --- /dev/null +++ b/scripts/start-fosfeno.sh @@ -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" diff --git a/scripts/start-projectm.sh b/scripts/start-projectm.sh new file mode 100755 index 0000000..c99438b --- /dev/null +++ b/scripts/start-projectm.sh @@ -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 diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..339d8b1 --- /dev/null +++ b/uninstall.sh @@ -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" diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..91a07d5 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/panel/index.html b/web/panel/index.html new file mode 100644 index 0000000..30bd11a --- /dev/null +++ b/web/panel/index.html @@ -0,0 +1,201 @@ + + + + + + FOSFENO :: Panel + + + + + +
+

F O S F E N O

+ +
+ + + + +
+ +
+
+ Visuales + +
+ +
+ + +
+
+ Motor de visuales + +
+
+ + + + + +
+
+ + +
+
+ Audio + +
+ + +
+ BPM detectado + -- +
+

Si conectas el microfono o la camara con la Raspberry ya + encendida, pulsa este boton para que aparezcan.

+
+ + +
+
+ Sensibilidad al audio: + 1.0 + +
+ +
+ + + + + + + + + + + + + + +
+ Ahora suena +
-
+
+ + +
+ + +
+ +

FOSFENO

+
+ + + + + + + + + + + diff --git a/web/panel/panel.css b/web/panel/panel.css new file mode 100644 index 0000000..38188c4 --- /dev/null +++ b/web/panel/panel.css @@ -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; +} diff --git a/web/panel/panel.js b/web/panel/panel.js new file mode 100644 index 0000000..dde97e0 --- /dev/null +++ b/web/panel/panel.js @@ -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 = ''; + 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 = + `
${cfg.label}` + + `-
`; + 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 = ''; + } else if (sel.id === "mix-camera" && items.length === 0) { + keep = ''; + } + 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(); diff --git a/web/stage/index.html b/web/stage/index.html new file mode 100644 index 0000000..abc0c1f --- /dev/null +++ b/web/stage/index.html @@ -0,0 +1,69 @@ + + + + + + FOSFENO :: Escenario + + + +
FOSFENO
+ + +
+
F O S F E N O
+
Escanea el codigo con el movil, o abre la direccion:
+
buscando direccion...
+ codigo QR del panel +
+
+ + + + + + + + + + + + + diff --git a/web/stage/stage.js b/web/stage/stage.js new file mode 100644 index 0000000..e0142f8 --- /dev/null +++ b/web/stage/stage.js @@ -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();