FOSFENO: motor de visuales audio-reactivas para Raspberry Pi
Primera version. Cinco motores (projectM, Butterchurn, Hydra, Shaders GLSL y mezclador VJ con camara y video), panel de control web, deteccion de BPM propia, pantalla de conexion con codigo QR, instalador robusto para Raspberry Pi 4 y 5 y documentacion completa en docs/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
30a09fdee6
31 changed files with 3478 additions and 0 deletions
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Entorno Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Librerias web (las regenera install.sh)
|
||||
web/node_modules/
|
||||
web/lib/
|
||||
web/package-lock.json
|
||||
|
||||
# Presets de projectM (los descarga install.sh)
|
||||
data/presets-projectm/
|
||||
|
||||
# Videos del mezclador (los copia el usuario), pero conservamos el README
|
||||
data/videos/*
|
||||
!data/videos/README.txt
|
||||
|
||||
# Temporales
|
||||
*.log
|
||||
*.tmp
|
||||
170
README.md
Normal file
170
README.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||

|
||||
|
||||
# FOSFENO
|
||||
|
||||
**Motor de visuales audio-reactivas para Raspberry Pi.**
|
||||
Convierte una Raspberry Pi + un proyector + un micro USB en una estación de
|
||||
VJ automática: la Pi escucha la música de la sala, detecta su BPM y proyecta
|
||||
visuales que reaccionan al sonido. Todo se controla desde un panel web.
|
||||
|
||||
```
|
||||
[ Música en la sala ]
|
||||
│ (micro USB)
|
||||
▼
|
||||
┌──────────────────┐ Wi-Fi / Ethernet
|
||||
│ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.71/
|
||||
│ FOSFENO │ (panel en el móvil)
|
||||
└────────┬─────────┘
|
||||
│ micro-HDMI
|
||||
▼
|
||||
[ Proyector :: visuales ]
|
||||
```
|
||||
|
||||
## Documentación
|
||||
|
||||
La carpeta [`docs/`](docs/) tiene la guía completa:
|
||||
|
||||
- [Requisitos y hardware](docs/requisitos.md) — qué Raspberry, qué micrófono
|
||||
USB y qué cámara usar para que se reconozcan solos.
|
||||
- [Instalación](docs/instalacion.md) — qué descarga y compila el instalador.
|
||||
- [Conectarse al panel](docs/conexion.md) — el código QR, `fosfeno.local` y
|
||||
el router.
|
||||
- [Uso del panel](docs/uso.md) — cómo se maneja y qué hace cada motor.
|
||||
- [Solución de problemas](docs/problemas.md) — qué hacer cuando algo falla.
|
||||
|
||||
El panel además lleva un botón de información en cada apartado: explica qué
|
||||
es, qué necesita y cómo se configura, sin salir del propio panel.
|
||||
|
||||
## Motores de visuales
|
||||
|
||||
Los cinco se eligen y configuran desde el panel, en caliente:
|
||||
|
||||
| Motor | Qué es |
|
||||
|----------------|------------------------------------------------------------|
|
||||
| **projectM** | Visualizador MilkDrop nativo, compilado en la Pi |
|
||||
| **Butterchurn**| MilkDrop en WebGL, miles de presets |
|
||||
| **Hydra** | Código Hydra en vivo: editor + librería de fragmentos |
|
||||
| **Shaders** | Shaders GLSL audio-reactivos (estilo Shadertoy), con editor|
|
||||
| **Mezclador** | VJ tipo Resolume: cámara + clips de vídeo + efectos |
|
||||
|
||||
## Hardware necesario
|
||||
|
||||
- Raspberry Pi 5 o Pi 4 + fuente oficial + microSD/SSD
|
||||
- Cable **micro-HDMI → HDMI** para el proyector
|
||||
- **Micrófono USB** (la Pi no tiene entrada de audio propia)
|
||||
- **Webcam USB** (opcional, para el modo Mezclador)
|
||||
- Disipador o ventilador (las visuales tiran de GPU)
|
||||
- Red por Ethernet o Wi-Fi
|
||||
|
||||
## Instalación
|
||||
|
||||
En Raspberry Pi OS Bookworm (64 bits, con escritorio):
|
||||
|
||||
```bash
|
||||
git clone <URL-de-tu-gitlab>/fosfeno.git
|
||||
cd fosfeno
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
El instalador es robusto: detecta si es **Pi 4 o Pi 5**, comprueba el sistema
|
||||
operativo, **verifica las versiones** de cada herramienta (Python, Node, npm,
|
||||
CMake) y avisa con `[ OK ]` / `[ !! ]` / `[ XX ]` de cada paso.
|
||||
|
||||
```
|
||||
bash install.sh # instala todo (incluido projectM)
|
||||
bash install.sh --no-projectm # omite la compilación de projectM
|
||||
bash install.sh --check # solo comprueba el sistema, no instala nada
|
||||
```
|
||||
|
||||
Después:
|
||||
|
||||
```bash
|
||||
sudo raspi-config # System Options → Boot/Auto Login → Desktop Autologin
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Al reiniciar, la Pi arranca sola en modo kiosko mostrando las visuales.
|
||||
|
||||
## Uso
|
||||
|
||||
- **Visuales** → salen automáticamente por el proyector (HDMI).
|
||||
- **Panel de control** → al arrancar, el proyector muestra un **código QR** y
|
||||
la dirección. Escanéalo con el móvil y el panel se abre. También se llega
|
||||
escribiendo `http://fosfeno.local/`. El móvil debe estar en la misma red.
|
||||
Ver [Conectarse al panel](docs/conexion.md).
|
||||
|
||||
Desde el panel puedes:
|
||||
|
||||
- Encender/apagar las visuales y cambiar de motor.
|
||||
- Elegir la **tarjeta de audio** y ver el **BPM detectado** en vivo.
|
||||
- Ajustar la sensibilidad al audio.
|
||||
- **Butterchurn**: presets, transición, cambio automático por segundos o
|
||||
**sincronizado al compás**.
|
||||
- **Hydra / Shaders**: editor de código integrado para **escribir o pegar**
|
||||
tu propio código, más una **librería de fragmentos** lista para cargar.
|
||||
- **Mezclador VJ**: activar la cámara, elegir un clip de vídeo, modo de
|
||||
mezcla y efectos de color (tono, saturación, colorama, posterizado,
|
||||
pixelado, caleidoscopio, feedback, invertir…).
|
||||
|
||||
### Modo Mezclador (cámara + vídeo)
|
||||
|
||||
Copia tus clips en `data/videos/` (formatos `.mp4`, `.webm`, `.mov`…) y
|
||||
aparecerán en el panel. El mezclador genera código Hydra por debajo a partir
|
||||
de los controles, así que mezcla en tiempo real la webcam, los clips y los
|
||||
efectos de color — el equivalente a Resolume, pero corriendo en la propia Pi.
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
FOSFENO/
|
||||
├── install.sh / uninstall.sh Instalador robusto y desinstalador
|
||||
├── config.json Configuración (puerto, micro, valores por defecto)
|
||||
├── backend/server.py Servidor: web + WebSocket + gestión de procesos
|
||||
├── scripts/lib.sh Funciones de los scripts (logs, versiones)
|
||||
├── web/panel/ Panel de control (móvil)
|
||||
├── web/stage/ Escenario en Chromium (todos los motores web)
|
||||
├── docs/ Documentación completa
|
||||
├── data/hydra-sketches.json Sketches de Hydra de fábrica
|
||||
├── data/hydra-snippets.json Librería de fragmentos de Hydra para el editor
|
||||
├── data/shaders.json Shaders GLSL (editables)
|
||||
├── data/ayuda.json Textos de ayuda que muestra el panel
|
||||
└── data/videos/ Tus clips de vídeo para el Mezclador
|
||||
```
|
||||
|
||||
Cuando algo falla (un error de código, una cámara que no responde, projectM
|
||||
sin instalar), FOSFENO no se queda callado: el aviso aparece en una banda en
|
||||
la parte de arriba del panel, con el color según su gravedad.
|
||||
|
||||
## Detección de BPM
|
||||
|
||||
FOSFENO incluye un detector de ritmo propio (análisis de energía de graves
|
||||
en tiempo real) que estima el BPM de la música ambiente. El BPM se muestra en
|
||||
el panel y alimenta a todos los motores:
|
||||
|
||||
- **Shaders**: uniforms `u_bpm` y `u_beat` (fase 0..1 sincronizada al pulso).
|
||||
- **Hydra**: actualiza la variable global `bpm` (la usan `.fast()`, etc.).
|
||||
- **Butterchurn**: cambio de preset cada N compases.
|
||||
|
||||
## Configuración (`config.json`)
|
||||
|
||||
- `server.port` — puerto del panel. `80` permite `http://IP/` sin puerto;
|
||||
si da problemas de permisos, cámbialo a `8080`.
|
||||
- `audio.matchSource` — subcadena para localizar el micro USB (por defecto `usb`).
|
||||
- `defaults` — motor, sensibilidad, sketch de Hydra y shader al arrancar.
|
||||
|
||||
## Notas
|
||||
|
||||
- **Pi 5 usa Wayland.** El cambio manual de preset en projectM solo funciona
|
||||
en sesión X11; en Wayland projectM rota presets automáticamente. El resto
|
||||
de motores no se ven afectados.
|
||||
- Para el Mezclador, usa clips de vídeo ligeros (720p o menos, H.264).
|
||||
- Los fragmentos de Hydra de `data/hydra-snippets.json` están adaptados de
|
||||
ejemplos de la comunidad de Hydra
|
||||
([hydra-synth/hydra](https://github.com/hydra-synth/hydra),
|
||||
[zachkrall/hydra-examples](https://github.com/zachkrall/hydra-examples)).
|
||||
- Uniforms de los shaders GLSL: `u_resolution`, `u_time`, `u_bass`, `u_mid`,
|
||||
`u_treble`, `u_level`, `u_bpm`, `u_beat`, `u_fft`.
|
||||
|
||||
---
|
||||
|
||||
*Parte de COFRE/CODERS — creative coding audio-reactivo.*
|
||||
3
backend/requirements.txt
Normal file
3
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask>=3.0
|
||||
flask-socketio>=5.3
|
||||
simple-websocket>=1.0
|
||||
488
backend/server.py
Normal file
488
backend/server.py
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
FOSFENO - Motor de visuales audio-reactivas para Raspberry Pi OS.
|
||||
|
||||
Un unico proceso que:
|
||||
- sirve el PANEL de control (http://<ip>/)
|
||||
- sirve el ESCENARIO (http://<ip>/stage) -> lo abre Chromium en kiosko
|
||||
- lanza/cierra Chromium y el visualizador nativo projectM
|
||||
- mantiene el estado y lo sincroniza con panel y escenario via WebSocket
|
||||
|
||||
Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
|
||||
(mezclador VJ basado en Hydra: camara + video + efectos).
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Rutas y configuracion
|
||||
# --------------------------------------------------------------------------
|
||||
BASE = Path(__file__).resolve().parent.parent # .../FOSFENO
|
||||
WEB = BASE / "web"
|
||||
DATA = BASE / "data"
|
||||
VIDEOS = DATA / "videos"
|
||||
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"[FOSFENO] No se pudo leer {path}: {exc}")
|
||||
return default
|
||||
|
||||
|
||||
CFG = load_json(BASE / "config.json", {})
|
||||
HOST = CFG.get("server", {}).get("host", "0.0.0.0")
|
||||
PORT = int(CFG.get("server", {}).get("port", 80))
|
||||
|
||||
app = Flask(__name__, static_folder=None)
|
||||
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Datos cargados de disco
|
||||
# --------------------------------------------------------------------------
|
||||
HYDRA_SKETCHES = load_json(DATA / "hydra-sketches.json", {})
|
||||
SHADERS = load_json(DATA / "shaders.json", {})
|
||||
|
||||
DEFAULTS = CFG.get("defaults", {})
|
||||
ENGINES = ("projectm", "butterchurn", "hydra", "shaders", "mixer")
|
||||
|
||||
|
||||
def first_code(table, key, field="code"):
|
||||
"""Devuelve el codigo de una entrada de un diccionario de presets."""
|
||||
entry = table.get(key) or next(iter(table.values()), {})
|
||||
return entry.get(field, "") if isinstance(entry, dict) else ""
|
||||
|
||||
|
||||
def list_videos():
|
||||
if not VIDEOS.is_dir():
|
||||
return []
|
||||
return sorted(f.name for f in VIDEOS.iterdir()
|
||||
if f.suffix.lower() in VIDEO_EXT)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Estado global (se difunde a todos los clientes)
|
||||
# --------------------------------------------------------------------------
|
||||
NOTIFY_HISTORY = [] # ultimos avisos mostrados en el panel
|
||||
|
||||
state = {
|
||||
"engine": DEFAULTS.get("engine", "butterchurn"),
|
||||
"power": True,
|
||||
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
|
||||
"audio": {"device": "", "bpm": 0},
|
||||
"butterchurn": {
|
||||
"preset": "", "shuffle": True,
|
||||
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
|
||||
},
|
||||
"hydra": {
|
||||
"label": "",
|
||||
"code": first_code(HYDRA_SKETCHES, DEFAULTS.get("hydraSketch", "")),
|
||||
},
|
||||
"shaders": {
|
||||
"label": "",
|
||||
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
|
||||
},
|
||||
"mixer": {
|
||||
"source": "cam", "camOn": True, "cameraId": 0, "video": "",
|
||||
"mix": 0.5, "blendMode": "blend",
|
||||
"hue": 0.0, "saturate": 1.0, "contrast": 1.0, "brightness": 0.0,
|
||||
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
|
||||
"rotate": 0.0, "feedback": 0.0, "invert": False, "beatPulse": False,
|
||||
},
|
||||
"projectm": {"available": False},
|
||||
"meta": {
|
||||
"butterchurnPresets": [],
|
||||
"audioDevices": [],
|
||||
"cameraDevices": [],
|
||||
"videos": list_videos(),
|
||||
},
|
||||
"status": {"label": ""},
|
||||
"notifications": NOTIFY_HISTORY,
|
||||
"network": {"ip": "", "hostname": "", "port": PORT, "panelSeen": False},
|
||||
}
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def broadcast():
|
||||
socketio.emit("state", state)
|
||||
|
||||
|
||||
def notify(level, message):
|
||||
"""Registra un aviso y lo envia al panel.
|
||||
|
||||
level puede ser 'info', 'warn' o 'error'. Asi el usuario nunca se queda
|
||||
sin saber que ha pasado: cualquier fallo aparece en la parte de arriba
|
||||
del panel de control.
|
||||
"""
|
||||
entry = {"level": level, "message": str(message), "t": int(time.time())}
|
||||
print(f"[FOSFENO] aviso({level}): {message}")
|
||||
NOTIFY_HISTORY.append(entry)
|
||||
while len(NOTIFY_HISTORY) > 20:
|
||||
NOTIFY_HISTORY.pop(0)
|
||||
try:
|
||||
socketio.emit("notify", entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_lan_ip():
|
||||
"""Devuelve la IP de la Raspberry en la red local."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def refresh_network():
|
||||
"""Actualiza la IP y el nombre de red de la Raspberry. Devuelve True si la
|
||||
IP ha cambiado, para avisar al panel y al escenario."""
|
||||
new_ip = get_lan_ip()
|
||||
changed = state["network"]["ip"] != new_ip
|
||||
state["network"]["ip"] = new_ip
|
||||
state["network"]["hostname"] = socket.gethostname()
|
||||
return changed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gestion de procesos (Chromium kiosko y projectM nativo)
|
||||
# --------------------------------------------------------------------------
|
||||
procs = {"chromium": None, "projectm": None}
|
||||
|
||||
|
||||
def is_running(proc):
|
||||
return proc is not None and proc.poll() is None
|
||||
|
||||
|
||||
def start_chromium():
|
||||
"""Abre el escenario en Chromium a pantalla completa."""
|
||||
if is_running(procs["chromium"]):
|
||||
return
|
||||
kiosk = CFG.get("kiosk", {})
|
||||
browser = shutil.which(kiosk.get("browser", "chromium-browser")) \
|
||||
or shutil.which("chromium")
|
||||
if not browser:
|
||||
notify("error", "No se encontro Chromium. Las visuales web no pueden "
|
||||
"mostrarse. Ejecuta install.sh para instalarlo.")
|
||||
return
|
||||
url = kiosk.get("url", f"http://localhost:{PORT}/stage")
|
||||
procs["chromium"] = subprocess.Popen([
|
||||
browser,
|
||||
"--kiosk", f"--app={url}",
|
||||
"--use-fake-ui-for-media-stream", # acepta micro y camara sin preguntar
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--noerrdialogs", "--disable-infobars", "--disable-translate",
|
||||
"--disable-features=Translate",
|
||||
"--check-for-update-interval=31536000",
|
||||
"--ozone-platform-hint=auto",
|
||||
])
|
||||
print("[FOSFENO] Chromium abierto en", url)
|
||||
|
||||
|
||||
def stop_projectm():
|
||||
proc = procs["projectm"]
|
||||
if is_running(proc):
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
procs["projectm"] = None
|
||||
|
||||
|
||||
def start_projectm():
|
||||
"""Lanza projectM nativo; su ventana se superpone a Chromium."""
|
||||
stop_projectm()
|
||||
script = BASE / "scripts" / "start-projectm.sh"
|
||||
try:
|
||||
procs["projectm"] = subprocess.Popen(["bash", str(script)])
|
||||
print("[FOSFENO] projectM lanzado.")
|
||||
except OSError as exc:
|
||||
notify("error", f"No se pudo lanzar projectM: {exc}. "
|
||||
"Prueba con otro motor de visuales.")
|
||||
|
||||
|
||||
def projectm_key(key):
|
||||
"""Envia una tecla a projectM (solo funciona en sesion X11, no Wayland)."""
|
||||
if not shutil.which("xdotool"):
|
||||
return
|
||||
subprocess.run(
|
||||
["xdotool", "search", "--name", "projectM",
|
||||
"key", "--clearmodifiers", key],
|
||||
timeout=3, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def apply_engine():
|
||||
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
|
||||
use_native = state["engine"] == "projectm" and state["power"]
|
||||
if state["engine"] == "projectm" and not state["projectm"]["available"]:
|
||||
notify("warn", "projectM no esta instalado en esta Raspberry. "
|
||||
"Elige otro motor o vuelve a compilarlo con "
|
||||
"scripts/build-projectm.sh.")
|
||||
if use_native and state["projectm"]["available"]:
|
||||
start_projectm()
|
||||
else:
|
||||
stop_projectm()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Rutas HTTP (panel, escenario, librerias, datos)
|
||||
# --------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def panel_index():
|
||||
return send_from_directory(str(WEB / "panel"), "index.html")
|
||||
|
||||
|
||||
@app.route("/panel/<path:fname>")
|
||||
def panel_files(fname):
|
||||
return send_from_directory(str(WEB / "panel"), fname)
|
||||
|
||||
|
||||
@app.route("/stage")
|
||||
def stage_index():
|
||||
return send_from_directory(str(WEB / "stage"), "index.html")
|
||||
|
||||
|
||||
@app.route("/stage/<path:fname>")
|
||||
def stage_files(fname):
|
||||
return send_from_directory(str(WEB / "stage"), fname)
|
||||
|
||||
|
||||
@app.route("/lib/<path:fname>")
|
||||
def lib_files(fname):
|
||||
return send_from_directory(str(WEB / "lib"), fname)
|
||||
|
||||
|
||||
@app.route("/data/<path:fname>")
|
||||
def data_files(fname):
|
||||
return send_from_directory(str(DATA), fname)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Eventos WebSocket
|
||||
# --------------------------------------------------------------------------
|
||||
@socketio.on("connect")
|
||||
def on_connect():
|
||||
socketio.emit("state", state)
|
||||
|
||||
|
||||
@socketio.on("hello")
|
||||
def on_hello(data):
|
||||
"""El panel se presenta al conectarse. Asi el escenario sabe que alguien
|
||||
ya ha encontrado el panel y puede dejar de mostrar el codigo QR."""
|
||||
if (data or {}).get("role") == "panel" and not state["network"]["panelSeen"]:
|
||||
with lock:
|
||||
state["network"]["panelSeen"] = True
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("set_engine")
|
||||
def on_set_engine(data):
|
||||
engine = (data or {}).get("engine")
|
||||
if engine not in ENGINES:
|
||||
return
|
||||
with lock:
|
||||
state["engine"] = engine
|
||||
apply_engine()
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("set_power")
|
||||
def on_set_power(data):
|
||||
with lock:
|
||||
state["power"] = bool((data or {}).get("on"))
|
||||
apply_engine()
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("set_sensitivity")
|
||||
def on_set_sensitivity(data):
|
||||
try:
|
||||
value = float((data or {}).get("value", 1.0))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
with lock:
|
||||
state["sensitivity"] = max(0.0, min(4.0, value))
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("run_code")
|
||||
def on_run_code(data):
|
||||
"""Ejecuta codigo (Hydra o GLSL) escrito/pegado o un fragmento de la libreria."""
|
||||
engine = (data or {}).get("engine")
|
||||
code = (data or {}).get("code")
|
||||
label = (data or {}).get("label", "codigo personalizado")
|
||||
if engine in ("hydra", "shaders") and isinstance(code, str):
|
||||
with lock:
|
||||
state[engine]["code"] = code
|
||||
state[engine]["label"] = label
|
||||
state["status"]["label"] = label
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("update_settings")
|
||||
def on_update_settings(data):
|
||||
"""Actualiza ajustes de un motor: butterchurn, mixer o audio."""
|
||||
engine = (data or {}).get("engine")
|
||||
patch = (data or {}).get("patch", {})
|
||||
if engine in ("butterchurn", "mixer", "audio") and isinstance(patch, dict):
|
||||
with lock:
|
||||
state[engine].update(patch)
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("engine_command")
|
||||
def on_engine_command(data):
|
||||
action = (data or {}).get("action")
|
||||
if state["engine"] == "projectm":
|
||||
projectm_key({"next": "n", "prev": "p"}.get(action, "n"))
|
||||
else:
|
||||
socketio.emit("stage_command", {"action": action,
|
||||
"value": (data or {}).get("value")})
|
||||
|
||||
|
||||
@socketio.on("stage_meta")
|
||||
def on_stage_meta(data):
|
||||
"""El escenario nos envia las listas que solo conoce el navegador."""
|
||||
data = data or {}
|
||||
with lock:
|
||||
if isinstance(data.get("butterchurnPresets"), list):
|
||||
state["meta"]["butterchurnPresets"] = data["butterchurnPresets"]
|
||||
if isinstance(data.get("audioDevices"), list):
|
||||
state["meta"]["audioDevices"] = data["audioDevices"]
|
||||
if isinstance(data.get("cameraDevices"), list):
|
||||
state["meta"]["cameraDevices"] = data["cameraDevices"]
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("rescan_devices")
|
||||
def on_rescan_devices():
|
||||
"""Pide al escenario que vuelva a buscar microfonos y camaras."""
|
||||
socketio.emit("stage_rescan")
|
||||
|
||||
|
||||
@socketio.on("stage_status")
|
||||
def on_stage_status(data):
|
||||
if isinstance(data, dict):
|
||||
with lock:
|
||||
if "label" in data:
|
||||
state["status"]["label"] = data["label"]
|
||||
if "bpm" in data:
|
||||
state["audio"]["bpm"] = data["bpm"]
|
||||
socketio.emit("status", {"label": state["status"]["label"],
|
||||
"bpm": state["audio"]["bpm"]})
|
||||
|
||||
|
||||
@socketio.on("stage_notify")
|
||||
def on_stage_notify(data):
|
||||
"""El escenario (navegador) nos comunica un error para mostrarlo en el panel."""
|
||||
data = data or {}
|
||||
notify(data.get("level", "error"),
|
||||
data.get("message", "Error en el escenario de visuales."))
|
||||
|
||||
|
||||
@socketio.on("rescan_videos")
|
||||
def on_rescan_videos():
|
||||
with lock:
|
||||
state["meta"]["videos"] = list_videos()
|
||||
broadcast()
|
||||
|
||||
|
||||
@socketio.on("system")
|
||||
def on_system(data):
|
||||
action = (data or {}).get("action")
|
||||
if action == "reboot":
|
||||
subprocess.Popen(["sudo", "reboot"])
|
||||
elif action == "shutdown":
|
||||
subprocess.Popen(["sudo", "poweroff"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Arranque
|
||||
# --------------------------------------------------------------------------
|
||||
def setup_audio():
|
||||
"""Marca el microfono USB como fuente de audio por defecto (best-effort)."""
|
||||
match = CFG.get("audio", {}).get("matchSource", "usb").lower()
|
||||
try:
|
||||
out = subprocess.check_output(["pactl", "list", "short", "sources"],
|
||||
text=True, timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return
|
||||
for line in out.splitlines():
|
||||
cols = line.split("\t")
|
||||
if len(cols) >= 2 and match in cols[1].lower() \
|
||||
and "monitor" not in cols[1].lower():
|
||||
subprocess.run(["pactl", "set-default-source", cols[1]], check=False)
|
||||
print("[FOSFENO] Microfono por defecto:", cols[1])
|
||||
return
|
||||
notify("warn", "No se detecto ningun microfono USB. Conecta uno y "
|
||||
"reinicia, o elige la entrada a mano en el panel.")
|
||||
|
||||
|
||||
def watchdog():
|
||||
"""Reabre Chromium si se cierra (salvo cuando projectM esta activo)."""
|
||||
while True:
|
||||
time.sleep(10)
|
||||
if refresh_network():
|
||||
broadcast()
|
||||
if state["engine"] != "projectm" and not is_running(procs["chromium"]):
|
||||
notify("warn", "El navegador de las visuales se cerro. "
|
||||
"Reabriendolo automaticamente.")
|
||||
start_chromium()
|
||||
|
||||
|
||||
def check_install():
|
||||
"""Avisa al panel si la instalacion esta incompleta."""
|
||||
needed = ["socket.io.min.js", "butterchurn.min.js",
|
||||
"butterchurn-presets.min.js", "hydra-synth.js"]
|
||||
missing = [f for f in needed if not (WEB / "lib" / f).exists()]
|
||||
if missing:
|
||||
notify("error", "Faltan librerias web (" + ", ".join(missing) +
|
||||
"). Vuelve a ejecutar install.sh.")
|
||||
if not (WEB / "lib" / "codemirror" / "codemirror.js").exists():
|
||||
notify("warn", "Falta CodeMirror: el editor de codigo no funcionara. "
|
||||
"Vuelve a ejecutar install.sh.")
|
||||
|
||||
|
||||
def main():
|
||||
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
|
||||
state["projectm"]["available"] = shutil.which(binary) is not None
|
||||
print(f"[FOSFENO] projectM nativo: "
|
||||
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
|
||||
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
|
||||
|
||||
check_install()
|
||||
setup_audio()
|
||||
refresh_network()
|
||||
threading.Timer(3.0, start_chromium).start()
|
||||
threading.Thread(target=watchdog, daemon=True).start()
|
||||
|
||||
net = state["network"]
|
||||
suffix = "" if PORT == 80 else f":{PORT}"
|
||||
print("[FOSFENO] ===================================================")
|
||||
print("[FOSFENO] Panel de control disponible en:")
|
||||
print(f"[FOSFENO] http://{net['ip']}{suffix}/")
|
||||
print(f"[FOSFENO] http://{net['hostname']}.local{suffix}/")
|
||||
print("[FOSFENO] ===================================================")
|
||||
socketio.run(app, host=HOST, port=PORT, allow_unsafe_werkzeug=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
config.json
Normal file
27
config.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 80
|
||||
},
|
||||
"kiosk": {
|
||||
"url": "http://localhost/stage",
|
||||
"browser": "chromium-browser"
|
||||
},
|
||||
"audio": {
|
||||
"matchSource": "usb",
|
||||
"_comment": "Subcadena para identificar el micro USB entre las fuentes de audio del sistema"
|
||||
},
|
||||
"projectm": {
|
||||
"binary": "projectMSDL"
|
||||
},
|
||||
"network": {
|
||||
"hostname": "fosfeno",
|
||||
"_comment": "Nombre de red de la Raspberry. El panel queda en http://<hostname>.local/"
|
||||
},
|
||||
"defaults": {
|
||||
"engine": "butterchurn",
|
||||
"sensitivity": 1.0,
|
||||
"hydraSketch": "kaleido",
|
||||
"shader": "tunel"
|
||||
}
|
||||
}
|
||||
86
data/ayuda.json
Normal file
86
data/ayuda.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"_info": "Textos de ayuda que muestra el panel al pulsar los botones de informacion.",
|
||||
"ayuda": {
|
||||
"power": {
|
||||
"title": "Encendido de las visuales",
|
||||
"body": [
|
||||
"Pone en marcha o detiene las visuales que salen por el proyector.",
|
||||
"Apagar deja la pantalla en negro pero NO apaga la Raspberry. Es util para hacer una pausa sin tener que reiniciar nada.",
|
||||
"Para apagar la Raspberry de verdad, usa los botones de Reiniciar y Apagar del final del panel."
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"title": "Motor de visuales",
|
||||
"body": [
|
||||
"FOSFENO tiene cinco motores de visuales distintos. Solo uno funciona a la vez.",
|
||||
"Cada boton redondo cambia al motor correspondiente. Al cambiar de motor aparecen debajo sus propios controles.",
|
||||
"Pulsa el boton de informacion de cada motor para saber que hace, que necesita y como se configura."
|
||||
]
|
||||
},
|
||||
"projectm": {
|
||||
"title": "Motor projectM",
|
||||
"body": [
|
||||
"Que es: el visualizador clasico de MilkDrop, compilado dentro de la Raspberry. Reacciona al audio por si solo y va rotando entre miles de presets.",
|
||||
"Requisitos: se compila durante la instalacion. Si la compilacion fallo, este motor sale como no disponible y hay que volver a compilarlo. Los otros cuatro motores funcionan sin el.",
|
||||
"Como se configura: projectM es un programa nativo, su ventana se pone por encima de las demas visuales. Los botones Anterior y Siguiente cambian de preset, pero eso solo funciona en sesion X11; en Wayland projectM rota presets automaticamente."
|
||||
]
|
||||
},
|
||||
"butterchurn": {
|
||||
"title": "Motor Butterchurn",
|
||||
"body": [
|
||||
"Que es: MilkDrop reescrito para el navegador, con los mismos miles de presets. Reacciona al audio.",
|
||||
"Requisitos: ninguno especial, se descarga durante la instalacion.",
|
||||
"Como se configura: puedes elegir un preset concreto en la lista, o activar el cambio automatico. El cambio automatico puede ir por segundos o sincronizado al compas de la musica. El control de transicion ajusta cuanto dura el fundido entre un preset y el siguiente."
|
||||
]
|
||||
},
|
||||
"hydra": {
|
||||
"title": "Motor Hydra",
|
||||
"body": [
|
||||
"Que es: visuales generadas por codigo. Hydra es un lenguaje para escribir visuales en vivo.",
|
||||
"Requisitos: ninguno especial, se descarga durante la instalacion.",
|
||||
"Como se configura: el editor de codigo te deja escribir o pegar codigo de Hydra y ejecutarlo al momento. La libreria trae fragmentos listos para usar. En el codigo tienes disponibles time, los valores de audio a.fft[0] a a.fft[4] y la variable bpm."
|
||||
]
|
||||
},
|
||||
"shaders": {
|
||||
"title": "Motor Shaders",
|
||||
"body": [
|
||||
"Que es: shaders GLSL, el tipo de visual de Shadertoy. Programas que dibujan cada pixel de la pantalla.",
|
||||
"Requisitos: ninguno especial. Los shaders pesados cargan la GPU; si van a tirones, usa un disipador o cambia a un motor mas ligero.",
|
||||
"Como se configura: el editor de codigo te deja escribir o pegar shaders GLSL. Tienes los uniforms u_time, u_bass, u_mid, u_treble, u_level, u_bpm, u_beat y la textura u_fft con el espectro de audio."
|
||||
]
|
||||
},
|
||||
"mixer": {
|
||||
"title": "Motor Mezclador VJ",
|
||||
"body": [
|
||||
"Que es: el modo de video. Mezcla la imagen de una camara web con clips de video y efectos de color. Es lo mas parecido a un programa de VJ como Resolume.",
|
||||
"Requisitos: una webcam USB que cumpla el estandar UVC, conectada antes de encender la Raspberry. Para los clips, copia tus archivos de video en la carpeta data/videos del proyecto.",
|
||||
"Como se configura: elige la fuente (camara, video o mezcla de las dos), el modo de mezcla y los efectos de color con los controles deslizantes. La casilla de pulso al ritmo hace que la imagen lata con los graves.",
|
||||
"Si tienes mas de una camara, eligela en la lista de camaras del panel. El boton Actualizar lista de videos vuelve a leer la carpeta data/videos por si has copiado clips nuevos."
|
||||
]
|
||||
},
|
||||
"audio": {
|
||||
"title": "Audio y BPM",
|
||||
"body": [
|
||||
"Que es: aqui se elige por que entrada escucha FOSFENO la musica, y se ve el BPM que detecta.",
|
||||
"Requisitos: un microfono USB o una tarjeta de sonido USB con entrada. La Raspberry no tiene entrada de audio propia.",
|
||||
"Como se configura: por defecto FOSFENO coge el microfono USB automaticamente. Si tienes varias entradas, eligela en la lista. El BPM se calcula solo a partir del sonido y tarda unos segundos en estabilizarse.",
|
||||
"Si conectas el microfono o la camara con la Raspberry ya encendida, pulsa el boton Buscar dispositivos de nuevo y apareceran en sus listas sin tener que reiniciar."
|
||||
]
|
||||
},
|
||||
"sensibilidad": {
|
||||
"title": "Sensibilidad al audio",
|
||||
"body": [
|
||||
"Que es: ajusta cuanto reaccionan las visuales al volumen de la musica.",
|
||||
"Como se configura: si la sala suena floja y las visuales se quedan quietas, sube la sensibilidad. Si todo se ve saturado y exagerado, bajala. El valor 1 es el punto de partida normal."
|
||||
]
|
||||
},
|
||||
"editor": {
|
||||
"title": "Editor de codigo",
|
||||
"body": [
|
||||
"Que es: un editor para escribir, pegar y ejecutar codigo de visuales en vivo. Aparece en los motores Hydra y Shaders.",
|
||||
"Como se configura: elige un ejemplo de la libreria y se carga en el editor, listo para ejecutarse. Puedes modificarlo o pegar codigo tuyo. El boton Ejecutar lanza lo que haya en el editor. El boton Limpiar lo vacia.",
|
||||
"Si el codigo tiene un error, FOSFENO lo avisa en la parte de arriba del panel y el detalle tecnico queda en la consola del navegador."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
data/hydra-sketches.json
Normal file
22
data/hydra-sketches.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"kaleido": {
|
||||
"name": "Caleidoscopio",
|
||||
"code": "osc(10, 0.1, () => 1 + a.fft[0]*2).color(0.9,0.2,0.8).rotate(() => time*0.1).kaleid(() => 3 + Math.round(a.fft[2]*6)).modulate(noise(() => 1 + a.fft[1]*3), 0.3).out(o0)"
|
||||
},
|
||||
"tunel": {
|
||||
"name": "Tunel",
|
||||
"code": "shape(99, 0.15, 0.5).repeat(() => 2 + a.fft[0]*5, () => 2 + a.fft[1]*5).modulateScale(osc(4), () => a.fft[2]).scale(() => 1 + a.fft[0]).color(0.2,0.8,1.0).out(o0)"
|
||||
},
|
||||
"plasma": {
|
||||
"name": "Plasma",
|
||||
"code": "osc(6, 0, () => a.fft[0]*4).modulate(osc(6).rotate(1.2), 0.4).color(() => 0.5+a.fft[1], 0.3, () => 0.8+a.fft[2]).saturate(2).out(o0)"
|
||||
},
|
||||
"celdas": {
|
||||
"name": "Celdas",
|
||||
"code": "voronoi(() => 4 + a.fft[0]*22, 0.3, 0.2).color(1.0,0.3,0.6).modulatePixelate(noise(3), 0.2).rotate(() => time*0.05).out(o0)"
|
||||
},
|
||||
"estrellas": {
|
||||
"name": "Estrellas",
|
||||
"code": "noise(() => 3 + a.fft[1]*6, 0.15).thresh(() => 0.62 - a.fft[0]*0.45).color(0.7,0.9,1.0).modulateRotate(osc(2), 0.2).out(o0)"
|
||||
}
|
||||
}
|
||||
55
data/hydra-snippets.json
Normal file
55
data/hydra-snippets.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"_info": "Libreria de fragmentos de codigo Hydra para el editor de FOSFENO. Adaptados de ejemplos de la comunidad de Hydra (github.com/hydra-synth/hydra y github.com/zachkrall/hydra-examples). Edita o anade los tuyos libremente.",
|
||||
"snippets": {
|
||||
"osc-basico": {
|
||||
"name": "Oscilador basico",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(20, 0.1, 0.8)\n .out(o0)"
|
||||
},
|
||||
"kaleido-audio": {
|
||||
"name": "Caleidoscopio reactivo",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(15, 0.1, 1)\n .kaleid(() => 3 + a.fft[0]*8)\n .color(1, 0.4, 0.8)\n .rotate(() => time*0.1)\n .out(o0)"
|
||||
},
|
||||
"feedback": {
|
||||
"name": "Feedback infinito",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(4, 0.1, 1.2)\n .modulate(o0, () => 0.4 + a.fft[0]*0.5)\n .color(0.9, 0.3, 0.6)\n .out(o0)"
|
||||
},
|
||||
"voronoi-liquido": {
|
||||
"name": "Voronoi liquido",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "voronoi(8, 0.3, 0.2)\n .modulate(osc(5).rotate(0.7), 0.4)\n .color(0.2, 0.8, 1.0)\n .out(o0)"
|
||||
},
|
||||
"lluvia-pixel": {
|
||||
"name": "Lluvia de pixeles",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "noise(() => 4 + a.fft[1]*8, 0.1)\n .thresh(0.7)\n .modulate(noise(2).scrollY(0, 0.2))\n .color(0.6, 0.9, 1.0)\n .out(o0)"
|
||||
},
|
||||
"tunel": {
|
||||
"name": "Tunel pulsante",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "shape(99, 0.0001, 0.5)\n .repeat(() => 3 + a.fft[0]*3, () => 3 + a.fft[0]*3)\n .modulateScale(osc(4), () => 0.2 + a.fft[2])\n .color(1.0, 0.3, 0.7)\n .out(o0)"
|
||||
},
|
||||
"osc-modulado": {
|
||||
"name": "Oscilador modulado",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(40, 0.1, () => a.fft[0]*3)\n .modulate(osc(10).rotate(() => time*0.2), 0.5)\n .saturate(1.6)\n .out(o0)"
|
||||
},
|
||||
"triangulos": {
|
||||
"name": "Triangulos al ritmo",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "shape(3, () => 0.2 + a.fft[0]*0.4, 0.1)\n .repeat(5, 5)\n .rotate(() => time*0.1)\n .color(0.1, 0.9, 0.8)\n .out(o0)"
|
||||
},
|
||||
"espiral": {
|
||||
"name": "Espiral cromatica",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(10, 0.05, 1)\n .kaleid(() => 2 + a.fft[1]*10)\n .scale(() => 1 + a.fft[0])\n .rotate(() => time*0.3)\n .colorama(() => a.fft[2]*0.5)\n .out(o0)"
|
||||
},
|
||||
"glitch-rgb": {
|
||||
"name": "Glitch RGB",
|
||||
"author": "Hydra (comunidad)",
|
||||
"code": "osc(30, 0, 1)\n .modulate(noise(() => 2 + a.fft[0]*6), 0.3)\n .color(2.0, 0.5, 0.5)\n .modulateRotate(osc(2), 0.1)\n .out(o0)"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
data/shaders.json
Normal file
18
data/shaders.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"plasma": {
|
||||
"name": "Plasma neon",
|
||||
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n float t = u_time*0.4;\n float v = sin(uv.x*4.0 + t) + sin(uv.y*4.0 - t)\n + sin((uv.x+uv.y)*4.0 + t)\n + sin(length(uv)*8.0 - t*2.0 - u_bass*6.0);\n v += u_treble*4.0;\n vec3 col = 0.5 + 0.5*cos(vec3(0.0,2.0,4.0) + v + u_mid*3.0);\n col *= 0.6 + u_level*1.4;\n gl_FragColor = vec4(col,1.0);\n}"
|
||||
},
|
||||
"tunel": {
|
||||
"name": "Tunel infinito",
|
||||
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n float a = atan(uv.y,uv.x);\n float r = length(uv);\n float d = 0.3/r + u_time*0.6 + u_bass*1.8;\n float rings = sin(d*10.0)*0.5+0.5;\n float spokes = sin(a*8.0 + u_time)*0.5+0.5;\n vec3 col = mix(vec3(0.04,0.0,0.18), vec3(0.0,1.0,0.9), rings*spokes);\n col += vec3(1.0,0.2,0.6)*u_treble*spokes*2.0;\n col *= smoothstep(0.0,0.45,r);\n gl_FragColor = vec4(col,1.0);\n}"
|
||||
},
|
||||
"rejilla": {
|
||||
"name": "Rejilla cyber",
|
||||
"code": "void main(){\n vec2 uv = (gl_FragCoord.xy*2.0 - u_resolution)/u_resolution.y;\n uv *= 1.0 + u_bass*0.9;\n uv.y += u_time*0.2;\n vec2 g = abs(fract(uv*4.0) - 0.5);\n float line = smoothstep(0.46,0.5, max(g.x,g.y));\n vec3 base = vec3(0.0,0.9,1.0) + vec3(u_mid*1.5);\n vec3 col = mix(vec3(0.02,0.0,0.06), base, 1.0-line);\n col += vec3(1.0,0.1,0.8)*u_treble*(1.0-line)*2.0;\n gl_FragColor = vec4(col,1.0);\n}"
|
||||
},
|
||||
"espectro": {
|
||||
"name": "Espectro de barras",
|
||||
"code": "void main(){\n vec2 uv = gl_FragCoord.xy/u_resolution;\n float f = texture2D(u_fft, vec2(uv.x, 0.5)).r;\n float bar = step(uv.y, f);\n float crest = smoothstep(0.03, 0.0, abs(uv.y - f));\n vec3 col = mix(vec3(0.4,0.0,0.8), vec3(0.0,1.0,0.7), uv.x) * bar;\n col += vec3(1.0,1.0,1.0) * crest;\n gl_FragColor = vec4(col, 1.0);\n}"
|
||||
}
|
||||
}
|
||||
17
data/videos/README.txt
Normal file
17
data/videos/README.txt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FOSFENO :: carpeta de videos
|
||||
============================
|
||||
|
||||
Copia aqui tus clips de video para el modo "Mezclador VJ".
|
||||
|
||||
- Formatos: .mp4 (recomendado), .webm, .mov, .m4v, .ogv
|
||||
- Apareceran automaticamente en el panel de control.
|
||||
- Para mejor rendimiento en la Raspberry Pi usa clips:
|
||||
* en 720p o menos
|
||||
* codificados en H.264
|
||||
* de corta duracion (loops)
|
||||
|
||||
Ejemplo desde linea de comandos para reescalar un video pesado:
|
||||
|
||||
ffmpeg -i original.mp4 -vf scale=-2:720 -c:v libx264 -an clip.mp4
|
||||
|
||||
Los videos NO se suben al repositorio (estan en .gitignore).
|
||||
28
docs/README.md
Normal file
28
docs/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Documentación de FOSFENO
|
||||
|
||||
Esta carpeta reúne todo lo necesario para montar, instalar y usar FOSFENO sin
|
||||
tener que adivinar nada. Está pensada para leerse en orden la primera vez y
|
||||
para consultarse a saltos después.
|
||||
|
||||
- [Requisitos y hardware](requisitos.md)
|
||||
Qué Raspberry Pi necesitas, qué cable va al proyector y, sobre todo, qué
|
||||
micrófonos USB y qué cámaras funcionan sin instalar drivers.
|
||||
|
||||
- [Instalación](instalacion.md)
|
||||
Cómo se instala paso a paso y, con detalle, qué descarga y compila el
|
||||
script de instalación en tu Raspberry.
|
||||
|
||||
- [Conectarse al panel](conexion.md)
|
||||
Cómo encuentra el usuario el panel de control: el código QR del proyector,
|
||||
la dirección `fosfeno.local` y qué hacer con el router.
|
||||
|
||||
- [Uso del panel](uso.md)
|
||||
Cómo se maneja desde el móvil, qué hace cada motor de visuales y cómo se
|
||||
configura cada opción.
|
||||
|
||||
- [Solución de problemas](problemas.md)
|
||||
Qué hacer cuando algo no arranca, no se ve o no suena. Incluye cómo leer
|
||||
los mensajes de error que aparecen en el propio panel.
|
||||
|
||||
Si solo quieres empezar rápido, el archivo `README.md` de la raíz del
|
||||
proyecto tiene la versión resumida.
|
||||
16
docs/assets/README.md
Normal file
16
docs/assets/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Imágenes del proyecto
|
||||
|
||||
Aquí va el material gráfico del repositorio.
|
||||
|
||||
- `banner.svg` — cabecera del README. Imagen original, vectorial.
|
||||
|
||||
Cuando tengas FOSFENO montado, lo ideal es añadir aquí **fotos reales** de tu
|
||||
equipo y **capturas** de las visuales funcionando. Son más auténticas que
|
||||
cualquier imagen de catálogo y no tienen problemas de licencia. Sugerencias:
|
||||
|
||||
- Una foto de la Raspberry Pi conectada al proyector.
|
||||
- Una foto de la proyección en marcha durante un evento.
|
||||
- Capturas de los distintos motores (Butterchurn, Hydra, Shaders, Mezclador).
|
||||
|
||||
Guárdalas en esta carpeta y enlázalas desde el `README.md` o desde la
|
||||
documentación de `docs/`.
|
||||
51
docs/assets/banner.svg
Normal file
51
docs/assets/banner.svg
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="300" viewBox="0 0 960 300">
|
||||
<defs>
|
||||
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<linearGradient id="bars" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#b4ff00"/>
|
||||
<stop offset="1" stop-color="#ff7a00"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="960" height="300" fill="#060a06"/>
|
||||
|
||||
<!-- Ecualizador: barras reactivas al audio -->
|
||||
<g fill="url(#bars)" opacity="0.55">
|
||||
<rect x="8" y="270" width="24" height="30"/>
|
||||
<rect x="48" y="252" width="24" height="48"/>
|
||||
<rect x="88" y="232" width="24" height="68"/>
|
||||
<rect x="128" y="258" width="24" height="42"/>
|
||||
<rect x="168" y="278" width="24" height="22"/>
|
||||
<rect x="208" y="245" width="24" height="55"/>
|
||||
<rect x="248" y="218" width="24" height="82"/>
|
||||
<rect x="288" y="264" width="24" height="36"/>
|
||||
<rect x="328" y="272" width="24" height="28"/>
|
||||
<rect x="368" y="238" width="24" height="62"/>
|
||||
<rect x="408" y="254" width="24" height="46"/>
|
||||
<rect x="448" y="228" width="24" height="72"/>
|
||||
<rect x="488" y="266" width="24" height="34"/>
|
||||
<rect x="528" y="248" width="24" height="52"/>
|
||||
<rect x="568" y="276" width="24" height="24"/>
|
||||
<rect x="608" y="234" width="24" height="66"/>
|
||||
<rect x="648" y="260" width="24" height="40"/>
|
||||
<rect x="688" y="242" width="24" height="58"/>
|
||||
<rect x="728" y="268" width="24" height="32"/>
|
||||
<rect x="768" y="222" width="24" height="78"/>
|
||||
<rect x="808" y="262" width="24" height="38"/>
|
||||
<rect x="848" y="254" width="24" height="46"/>
|
||||
<rect x="888" y="236" width="24" height="64"/>
|
||||
<rect x="928" y="274" width="24" height="26"/>
|
||||
</g>
|
||||
|
||||
<text x="480" y="138" text-anchor="middle" font-family="Arial, Helvetica, sans-serif"
|
||||
font-size="86" font-weight="800" letter-spacing="16"
|
||||
fill="#b4ff00" filter="url(#glow)">FOSFENO</text>
|
||||
|
||||
<text x="480" y="182" text-anchor="middle" font-family="monospace"
|
||||
font-size="19" letter-spacing="2" fill="#cfe8ad">motor de visuales audio-reactivas para raspberry pi</text>
|
||||
|
||||
<line x1="350" y1="204" x2="610" y2="204" stroke="#ff7a00" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
89
docs/conexion.md
Normal file
89
docs/conexion.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Conectarse al panel
|
||||
|
||||
Esta es la parte donde, sin cuidado, la gente se atasca: la Raspberry funciona,
|
||||
las visuales salen por el proyector, pero el usuario no sabe en qué dirección
|
||||
está el panel de control. FOSFENO está pensado para que ese paso no exista
|
||||
como problema.
|
||||
|
||||
## La idea: encender y ver
|
||||
|
||||
Cuando la Raspberry arranca, antes de ponerse a dibujar visuales, el proyector
|
||||
muestra una pantalla de conexión durante unos segundos. En esa pantalla salen
|
||||
tres cosas:
|
||||
|
||||
- Un código QR grande.
|
||||
- La dirección del panel.
|
||||
- Un recordatorio de que el móvil tiene que estar en la misma red.
|
||||
|
||||
Esa misma pantalla vuelve a aparecer siempre que apagas las visuales desde el
|
||||
panel. Así, si en mitad de un evento alguien necesita la dirección, basta con
|
||||
darle a apagar y la tiene delante en el proyector.
|
||||
|
||||
## Tres formas de entrar, de la más fácil a la menos
|
||||
|
||||
**Escanear el código QR.** Abre la cámara del móvil, apúntala al QR que sale en
|
||||
el proyector y toca el aviso que aparece. Se abre el panel. No hay que escribir
|
||||
nada. Es la forma recomendada.
|
||||
|
||||
**Escribir el nombre.** En el navegador del móvil, escribe:
|
||||
|
||||
```
|
||||
http://fosfeno.local/
|
||||
```
|
||||
|
||||
Esta dirección no cambia nunca, da igual el día o la red. El instalador le pone
|
||||
a la Raspberry el nombre de red `fosfeno`, y `fosfeno.local` es la forma
|
||||
estándar de localizar un equipo por su nombre en la red local.
|
||||
|
||||
**Escribir la dirección IP.** Es la que aparece en la pantalla de conexión del
|
||||
proyector, algo como `http://192.168.1.71/`. Funciona siempre, pero la IP puede
|
||||
cambiar de un día para otro, así que es la opción de reserva.
|
||||
|
||||
## El móvil tiene que estar en la misma red
|
||||
|
||||
El panel es una página local: vive dentro de la Raspberry, no en internet. Para
|
||||
abrirla, el móvil y la Raspberry tienen que estar conectados a la misma red:
|
||||
el mismo router, ya sea por WiFi o por cable.
|
||||
|
||||
No funciona si el móvil está con datos móviles, ni si está en una red WiFi
|
||||
distinta (por ejemplo, una red de invitados separada).
|
||||
|
||||
## Sobre `fosfeno.local`
|
||||
|
||||
El nombre `.local` funciona gracias a un sistema llamado mDNS, que ya viene
|
||||
activado en Raspberry Pi OS. Lo entienden los iPhone, los Android modernos,
|
||||
Windows y Mac sin instalar nada.
|
||||
|
||||
Si en algún móvil viejo `fosfeno.local` no abre, no pasa nada: usa el código QR
|
||||
o la dirección IP, que aparecen igualmente en el proyector.
|
||||
|
||||
Puedes cambiar ese nombre. Edita `config.json`, el apartado `network.hostname`,
|
||||
y vuelve a ejecutar `bash install.sh`. El panel quedará entonces en
|
||||
`http://el-nombre-que-pongas.local/`.
|
||||
|
||||
## Sobre el router
|
||||
|
||||
No hace falta tocar nada en el router. FOSFENO no abre puertos hacia internet
|
||||
ni necesita configuración especial. Solo usa la red local.
|
||||
|
||||
Un único ajuste es recomendable, aunque opcional: entrar en el router y
|
||||
reservarle a la Raspberry siempre la misma IP (lo que se suele llamar reserva
|
||||
DHCP o IP fija). Así la dirección IP no cambia nunca. De todas formas, como el
|
||||
nombre `fosfeno.local` ya es fijo, puedes saltarte esto sin problema.
|
||||
|
||||
Para un evento, el cable de red entre la Raspberry y el router es más fiable
|
||||
que el WiFi.
|
||||
|
||||
## Si no consigues conectar
|
||||
|
||||
Repasa estas cosas, en orden:
|
||||
|
||||
- El móvil está en la misma red WiFi que la Raspberry, no en datos móviles.
|
||||
- Has escrito `http://` delante, y la barra `/` al final.
|
||||
- Prueba las tres vías: primero el QR, si no el nombre, si no la IP.
|
||||
- Mira la pantalla de conexión del proyector: la dirección que sale ahí es la
|
||||
buena en este momento.
|
||||
|
||||
Si el proyector no muestra ni siquiera la pantalla de conexión, el problema no
|
||||
es de red sino de arranque; en ese caso mira la guía de
|
||||
[solución de problemas](problemas.md).
|
||||
121
docs/instalacion.md
Normal file
121
docs/instalacion.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Instalación
|
||||
|
||||
La instalación es un único comando. Aun así, conviene saber qué hace por
|
||||
dentro, porque descarga y compila bastantes cosas y el proceso tarda.
|
||||
|
||||
## Antes de empezar
|
||||
|
||||
Necesitas Raspberry Pi OS Bookworm de 64 bits con el escritorio instalado, no
|
||||
la versión Lite. FOSFENO muestra las visuales dentro de una ventana de
|
||||
Chromium a pantalla completa, así que hace falta entorno gráfico.
|
||||
|
||||
Conviene tener la Raspberry conectada a internet por cable durante la
|
||||
instalación. Se descargan paquetes del sistema, librerías y el código fuente
|
||||
de projectM.
|
||||
|
||||
## El comando
|
||||
|
||||
```
|
||||
git clone <URL-de-tu-gitlab>/fosfeno.git
|
||||
cd fosfeno
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
El instalador va informando de cada paso con una marca delante:
|
||||
|
||||
- `[ OK ]` el paso terminó bien.
|
||||
- `[ !! ]` hubo un aviso. No es grave, pero conviene leerlo.
|
||||
- `[ XX ]` hubo un fallo que detiene la instalación.
|
||||
|
||||
Al final resume cuántos avisos hubo. Si todo sale con `[ OK ]`, la
|
||||
instalación está limpia.
|
||||
|
||||
## Qué se instala automáticamente
|
||||
|
||||
El script trabaja en nueve fases. Esto es lo que hace cada una.
|
||||
|
||||
**Comprobación del sistema.** Detecta si la placa es una Pi 4 o una Pi 5, mira
|
||||
la versión del sistema operativo y la arquitectura. Si algo no encaja avisa,
|
||||
pero te deja seguir.
|
||||
|
||||
**Paquetes del sistema.** Instala con apt lo que hace falta para que todo lo
|
||||
demás funcione:
|
||||
|
||||
- Python 3 y su gestor de entornos virtuales.
|
||||
- Chromium, el navegador donde se dibujan las visuales.
|
||||
- Node.js y npm, para descargar las librerías de visuales.
|
||||
- Git, CMake y las herramientas de compilación, necesarias para projectM.
|
||||
- Las librerías de desarrollo de SDL2, OpenGL ES y POCO, que projectM usa al
|
||||
compilar.
|
||||
- Las utilidades de PulseAudio, para detectar el micrófono.
|
||||
- v4l-utils, para reconocer la cámara USB.
|
||||
- xdotool y unclutter, para el control de projectM y para esconder el ratón.
|
||||
|
||||
**Verificación de versiones.** Comprueba que Python, Node, npm y CMake
|
||||
cumplen una versión mínima. Si alguna se queda corta lo dice claramente.
|
||||
Cuando es CMake quien no llega, salta la compilación de projectM en lugar de
|
||||
fallar entera.
|
||||
|
||||
**Entorno de Python.** Crea un entorno virtual aislado e instala Flask y
|
||||
Flask-SocketIO, que son el servidor web del proyecto. Después comprueba que
|
||||
esas librerías se importan de verdad.
|
||||
|
||||
**Librerías de visuales.** Con npm descarga Butterchurn y sus presets, Hydra,
|
||||
el cliente de Socket.IO y CodeMirror, que es el editor de código del panel.
|
||||
Copia los archivos que se usan a una carpeta `web/lib` y verifica uno por uno
|
||||
que cada copia existe y no está vacía.
|
||||
|
||||
**projectM.** Compila projectM desde su código fuente. Esto es lo que más
|
||||
tarda, entre diez y veinte minutos en una Raspberry. Si la compilación falla,
|
||||
el instalador lo avisa y sigue adelante: FOSFENO funciona igual con los otros
|
||||
cuatro motores. Puedes saltarte este paso desde el principio con
|
||||
`bash install.sh --no-projectm`.
|
||||
|
||||
**Recursos.** Descarga un paquete de presets para projectM y crea la carpeta
|
||||
`data/videos`, donde copiarás tus clips para el modo Mezclador.
|
||||
|
||||
**Arranque automático.** Configura la Raspberry para que, al encender el
|
||||
escritorio, lance FOSFENO sola. También da permiso para reiniciar y apagar la
|
||||
placa desde el panel sin pedir contraseña.
|
||||
|
||||
**Puerto 80.** Da permiso a Python para usar el puerto 80, que es lo que
|
||||
permite abrir el panel en `http://la-ip-de-tu-pi/` sin escribir ningún número
|
||||
de puerto detrás.
|
||||
|
||||
## Después de instalar
|
||||
|
||||
Falta un ajuste que el script no puede hacer por ti. Hay que decirle a la
|
||||
Raspberry que arranque sola en el escritorio:
|
||||
|
||||
```
|
||||
sudo raspi-config
|
||||
```
|
||||
|
||||
Dentro, ve a `System Options`, luego `Boot / Auto Login`, y elige
|
||||
`Desktop Autologin`. Sal y reinicia:
|
||||
|
||||
```
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Al volver, la Raspberry arranca directa en modo kiosko mostrando las
|
||||
visuales. El panel queda disponible en `http://la-ip-de-tu-pi/`.
|
||||
|
||||
## Comprobar el sistema sin reinstalar
|
||||
|
||||
Si quieres revisar que todo sigue en su sitio, o diagnosticar un problema, hay
|
||||
un modo que solo comprueba y no toca nada:
|
||||
|
||||
```
|
||||
bash install.sh --check
|
||||
```
|
||||
|
||||
Lista las versiones de las herramientas y dice si projectM y Chromium están
|
||||
presentes.
|
||||
|
||||
## Volver a ejecutar el instalador
|
||||
|
||||
El script se puede ejecutar las veces que quieras. No repite el trabajo ya
|
||||
hecho: no vuelve a compilar projectM si ya está, ni vuelve a descargar los
|
||||
presets si ya están. Es seguro relanzarlo si una instalación se cortó a
|
||||
medias.
|
||||
117
docs/problemas.md
Normal file
117
docs/problemas.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Solución de problemas
|
||||
|
||||
FOSFENO intenta no quedarse callado cuando algo va mal. La mayoría de fallos
|
||||
salen como aviso en la parte de arriba del panel. Aun así, aquí tienes los
|
||||
casos más habituales y cómo resolverlos.
|
||||
|
||||
## El panel no carga
|
||||
|
||||
Comprueba que el móvil y la Raspberry están en la misma red. Prueba a entrar
|
||||
con la IP exacta de la Raspberry. Si configuraste el puerto 8080, recuerda
|
||||
escribir `:8080` detrás de la dirección.
|
||||
|
||||
Si la IP responde pero la página no aparece, puede que el servidor no haya
|
||||
arrancado. Conéctate a la Raspberry y míralo a mano:
|
||||
|
||||
```
|
||||
cd ~/fosfeno
|
||||
.venv/bin/python3 backend/server.py
|
||||
```
|
||||
|
||||
Los mensajes que imprime ahí, los que empiezan por `[FOSFENO]`, dicen qué
|
||||
está pasando.
|
||||
|
||||
## No se ven visuales en el proyector
|
||||
|
||||
Mira si el proyector tiene señal. Si está en negro pero el panel funciona,
|
||||
revisa que las visuales estén encendidas con el botón de encendido.
|
||||
|
||||
Si la pantalla está en negro y en el panel no hay forma de que aparezca nada,
|
||||
Chromium puede no haberse abierto. El servidor lo reabre solo cada pocos
|
||||
segundos, así que espera un poco. Si no, reinicia la Raspberry desde el panel
|
||||
o desde la consola.
|
||||
|
||||
## No hay sonido o el BPM marca cero
|
||||
|
||||
El BPM en cero quiere decir que FOSFENO no está recibiendo audio, o que el
|
||||
audio es demasiado flojo.
|
||||
|
||||
Comprueba que el micrófono USB está conectado. En el apartado Audio del panel,
|
||||
abre el desplegable y elige a mano la entrada correcta. Si tu micrófono no
|
||||
aparece, conéctalo y reinicia la Raspberry, porque la lista se hace al
|
||||
arrancar.
|
||||
|
||||
Si el audio entra pero el BPM no se estabiliza, sube la sensibilidad. El
|
||||
detector de ritmo necesita música con un pulso claro; con música muy suave o
|
||||
sin percusión puede no encontrar el tempo, y a veces marca el doble o la
|
||||
mitad del valor real.
|
||||
|
||||
## La cámara no funciona en el Mezclador
|
||||
|
||||
Tiene que ser una webcam USB que cumpla el estándar UVC. El módulo de cámara
|
||||
con cable plano de la Raspberry no sirve en esta versión.
|
||||
|
||||
Conecta la cámara antes de encender la Raspberry. Si la conectas con el
|
||||
sistema ya arrancado, reinicia. Si en el panel sale un aviso de que la cámara
|
||||
no responde, es que el sistema no la ve; pruébala en otro puerto USB o con
|
||||
otro cable.
|
||||
|
||||
## projectM no está disponible
|
||||
|
||||
Si al elegir projectM sale un aviso de que no está instalado, es que su
|
||||
compilación falló o se saltó durante la instalación. No es grave: los otros
|
||||
cuatro motores funcionan igual.
|
||||
|
||||
Para intentar compilarlo de nuevo:
|
||||
|
||||
```
|
||||
cd ~/fosfeno
|
||||
bash scripts/build-projectm.sh
|
||||
```
|
||||
|
||||
Ese script avisa en qué paso falla. Lo más habitual es que falte alguna
|
||||
librería de desarrollo; vuelve a lanzar `bash install.sh` para reinstalar las
|
||||
dependencias del sistema.
|
||||
|
||||
## Las visuales van a tirones
|
||||
|
||||
Suele ser calor. Una Raspberry sin disipador, después de un rato con visuales,
|
||||
se calienta y baja su velocidad para protegerse. Ponle disipador o ventilador.
|
||||
|
||||
También ayuda usar motores más ligeros. Butterchurn e Hydra van más sueltos
|
||||
que los shaders pesados o que el mezclador de vídeo. En el Mezclador, usa
|
||||
clips de vídeo en 720p o menos.
|
||||
|
||||
## Un shader o un código de Hydra da error
|
||||
|
||||
Cuando un shader no compila, o un código de Hydra falla, sale un aviso en el
|
||||
panel diciéndolo. La descripción detallada del error aparece en la consola del
|
||||
navegador de la Raspberry, no en el panel, porque suele ser un mensaje
|
||||
técnico largo.
|
||||
|
||||
Revisa el código. Si lo pegaste de fuera, comprueba que es código de Hydra y
|
||||
no de otra herramienta, y que está completo.
|
||||
|
||||
## Revisar el estado del sistema
|
||||
|
||||
Para comprobar de una vez si las herramientas están bien instaladas:
|
||||
|
||||
```
|
||||
cd ~/fosfeno
|
||||
bash install.sh --check
|
||||
```
|
||||
|
||||
No instala nada. Solo informa de las versiones y de si projectM y Chromium
|
||||
están presentes.
|
||||
|
||||
## Empezar de cero
|
||||
|
||||
Si quieres quitar FOSFENO sin borrar el código:
|
||||
|
||||
```
|
||||
cd ~/fosfeno
|
||||
bash uninstall.sh
|
||||
```
|
||||
|
||||
Eso quita el arranque automático y los permisos, y cierra los procesos. El
|
||||
código sigue en su carpeta por si quieres volver a instalarlo.
|
||||
97
docs/requisitos.md
Normal file
97
docs/requisitos.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Requisitos y hardware
|
||||
|
||||
FOSFENO funciona en una Raspberry Pi conectada a un proyector. La Raspberry
|
||||
escucha el sonido de la sala a través de un micrófono USB y dibuja visuales
|
||||
que reaccionan a la música. Todo se gobierna desde el móvil con un navegador.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
Sirve una Raspberry Pi 4 o una Raspberry Pi 5. La Pi 5 va más holgada con los
|
||||
shaders y el mezclador de vídeo, así que es la recomendada si vas a usar esos
|
||||
modos a menudo. La Pi 4 cumple de sobra para Butterchurn e Hydra.
|
||||
|
||||
Necesitas además:
|
||||
|
||||
- La fuente de alimentación oficial. Los visuales exigen GPU y CPU; con
|
||||
cargadores genéricos la Pi puede reiniciarse sola.
|
||||
- Una tarjeta microSD de 32 GB o más, o mejor un SSD por USB en la Pi 5.
|
||||
- Disipador o ventilador. Mantener visuales en bucle calienta la placa, y si
|
||||
se calienta de más reduce su velocidad y las visuales van a tirones.
|
||||
- Sistema operativo Raspberry Pi OS Bookworm de 64 bits, con escritorio.
|
||||
|
||||
## Conexión con el proyector
|
||||
|
||||
La Pi 4 y la Pi 5 llevan salida micro-HDMI, no HDMI normal. Necesitas un cable
|
||||
micro-HDMI a HDMI, o un adaptador. Cualquier proyector con entrada HDMI vale.
|
||||
La resolución la ajusta el sistema; si el proyector es de 1280x720 las
|
||||
visuales irán más finas que a 1080p.
|
||||
|
||||
## Red
|
||||
|
||||
El panel de control se abre desde el móvil, así que el móvil y la Raspberry
|
||||
tienen que estar en la misma red. El cable Ethernet es más estable que el
|
||||
WiFi para un evento, pero las dos opciones funcionan. Conviene fijar la IP de
|
||||
la Raspberry en el router para que no cambie entre un día y otro.
|
||||
|
||||
## Micrófono USB y audio
|
||||
|
||||
La Raspberry Pi no tiene entrada de audio. El conector jack de la placa es
|
||||
solo salida. Para que FOSFENO oiga la música hay que añadir una entrada por
|
||||
USB.
|
||||
|
||||
Funcionan sin instalar nada los dispositivos que cumplen el estándar USB
|
||||
Audio Class. En la práctica eso es casi todo lo que se vende hoy:
|
||||
|
||||
- Tarjetas de sonido USB. Son adaptadores pequeños con un USB por un lado y
|
||||
conectores jack por el otro. Las que traen entrada de micrófono o entrada
|
||||
de línea sirven para enchufar el sonido desde una mesa de mezclas o desde
|
||||
un móvil. Dan la señal más limpia.
|
||||
- Micrófonos USB. Los micrófonos de condensador tipo podcast (Fifine, Samson,
|
||||
Blue Snowball y similares) se conectan directos y captan el sonido de la
|
||||
sala. Es la opción más cómoda si no quieres tirar cables.
|
||||
- Interfaces de audio USB y auriculares USB con micro. También valen mientras
|
||||
sean USB Audio Class, que es lo normal.
|
||||
|
||||
Evita los micrófonos Bluetooth. Tienen retardo y FOSFENO no los selecciona de
|
||||
forma automática. Evita también cualquier aparato que pida un driver del
|
||||
fabricante; con audio es raro, pero los hay.
|
||||
|
||||
FOSFENO elige la entrada automáticamente al arrancar. Busca entre las fuentes
|
||||
de audio del sistema la primera cuyo nombre contenga la palabra `usb`. Si tu
|
||||
dispositivo no se llama así, puedes cambiar esa palabra clave en `config.json`,
|
||||
en el apartado `audio.matchSource`. Desde el panel también puedes elegir a
|
||||
mano cualquier entrada detectada.
|
||||
|
||||
Recomendación práctica: para un evento con mesa de DJ, una tarjeta USB con
|
||||
entrada de línea conectada por jack a la salida de la mesa. Para algo casero o
|
||||
una fiesta, un micrófono USB que recoja el ambiente.
|
||||
|
||||
## Cámara USB
|
||||
|
||||
La cámara solo hace falta para el modo Mezclador. FOSFENO está preparado para
|
||||
cámaras web USB.
|
||||
|
||||
Funcionan sin drivers las cámaras que cumplen el estándar USB Video Class,
|
||||
conocido como UVC. Prácticamente todas las cámaras web USB modernas lo
|
||||
cumplen: las de Logitech, las genéricas, las de portátil externas. Conéctala
|
||||
antes de encender la Raspberry y el sistema la reconoce sola. El navegador la
|
||||
ve a través de V4L2, el subsistema de vídeo de Linux.
|
||||
|
||||
Esta versión de FOSFENO no usa el módulo de cámara oficial de la Raspberry, el
|
||||
que va con cable plano al conector CSI. En Raspberry Pi OS Bookworm ese módulo
|
||||
funciona a través de libcamera y no aparece como cámara estándar para el
|
||||
navegador sin pasos extra. Si quieres cámara, usa una webcam USB UVC.
|
||||
|
||||
Recomendación práctica: cualquier webcam USB de 720p sirve. No hace falta
|
||||
nada caro. Si la cámara permite varias resoluciones, el navegador elige una
|
||||
compatible automáticamente.
|
||||
|
||||
## Resumen de la lista de la compra
|
||||
|
||||
- Raspberry Pi 4 o 5 con su fuente oficial.
|
||||
- microSD de 32 GB o SSD USB.
|
||||
- Disipador o ventilador.
|
||||
- Cable micro-HDMI a HDMI.
|
||||
- Micrófono USB o tarjeta de sonido USB con entrada.
|
||||
- Webcam USB UVC, solo si vas a usar el modo Mezclador.
|
||||
- Cable de red, o WiFi.
|
||||
103
docs/uso.md
Normal file
103
docs/uso.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# Uso del panel
|
||||
|
||||
Cuando la Raspberry arranca, las visuales salen solas por el proyector. Todo
|
||||
lo demás se controla desde el panel.
|
||||
|
||||
## Entrar en el panel
|
||||
|
||||
Al arrancar, el proyector muestra una pantalla con un código QR y la dirección
|
||||
del panel. Escanea el QR con la cámara del móvil y el panel se abre solo. Si
|
||||
prefieres escribirla, la dirección es `http://fosfeno.local/`.
|
||||
|
||||
El móvil tiene que estar en la misma red que la Raspberry. La explicación
|
||||
completa, con las tres formas de conectar y los detalles del router, está en
|
||||
[Conectarse al panel](conexion.md).
|
||||
|
||||
Arriba a la derecha del panel hay un punto. Verde quiere decir que está
|
||||
conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión.
|
||||
|
||||
## Avisos y errores
|
||||
|
||||
Justo debajo de la cabecera aparecen los avisos. Si algo va mal (la cámara no
|
||||
responde, un shader tiene un error de código, projectM no está instalado), el
|
||||
mensaje sale ahí en lugar de quedarse el sistema callado. Los avisos
|
||||
informativos desaparecen solos. Los errores se quedan hasta que los cierras,
|
||||
para que no se te escapen.
|
||||
|
||||
## Encendido
|
||||
|
||||
El botón grande de encendido pone las visuales en marcha o las apaga. Apagar
|
||||
deja el proyector en negro sin tener que apagar la Raspberry.
|
||||
|
||||
## Los cinco motores
|
||||
|
||||
Se eligen con los botones redondos. Solo uno está activo a la vez.
|
||||
|
||||
**projectM.** El visualizador clásico de MilkDrop, compilado en la propia
|
||||
Raspberry. Reacciona al audio por sí solo y va rotando entre miles de
|
||||
presets. Es un programa nativo, así que cuando lo eliges su ventana se pone
|
||||
por encima de las demás visuales.
|
||||
|
||||
**Butterchurn.** Es MilkDrop reescrito para el navegador. Tiene los mismos
|
||||
miles de presets. Puedes elegir un preset concreto, dejar que cambie solo cada
|
||||
ciertos segundos, o que cambie sincronizado con el ritmo de la música.
|
||||
|
||||
**Hydra.** Visuales generados por código. Trae un editor integrado donde
|
||||
puedes escribir o pegar código de Hydra y ejecutarlo al momento. Incluye una
|
||||
librería de fragmentos listos para usar; eliges uno y se carga en el editor.
|
||||
|
||||
**Shaders.** Shaders GLSL, el tipo de visual de Shadertoy. También trae editor
|
||||
de código. Los shaders reciben información del audio y del ritmo, así que se
|
||||
mueven con la música.
|
||||
|
||||
**Mezclador.** El modo de vídeo. Mezcla la imagen de una webcam USB con clips
|
||||
de vídeo y efectos de color. Es lo más parecido a un programa de VJ como
|
||||
Resolume, pero funcionando dentro de la Raspberry.
|
||||
|
||||
## Audio y BPM
|
||||
|
||||
La tarjeta de audio se elige en el apartado Audio del panel. Por defecto
|
||||
FOSFENO coge el micrófono USB automáticamente, pero si tienes varias entradas
|
||||
puedes cambiarla ahí.
|
||||
|
||||
Justo al lado se ve el BPM detectado. FOSFENO analiza el sonido y estima a
|
||||
cuántos pulsos por minuto va la música. Ese valor lo usan los motores para
|
||||
sincronizarse: los shaders, Hydra y el cambio de preset de Butterchurn al
|
||||
compás. El BPM tarda unos segundos en estabilizarse y funciona mejor con
|
||||
música de pulso marcado.
|
||||
|
||||
La sensibilidad ajusta cuánto reaccionan las visuales al volumen. Si la sala
|
||||
suena floja, súbela. Si todo se ve saturado, bájala.
|
||||
|
||||
## El editor de código
|
||||
|
||||
En Hydra y en Shaders aparece un editor. Funciona igual en los dos:
|
||||
|
||||
- El desplegable de la librería carga un ejemplo. Al elegirlo, el código entra
|
||||
en el editor y se ejecuta al momento.
|
||||
- Puedes modificar ese código o pegar uno tuyo. El botón Ejecutar lanza lo que
|
||||
haya en el editor.
|
||||
- El botón Limpiar vacía el editor.
|
||||
|
||||
En Hydra el código es JavaScript de Hydra y tienes disponibles `time`, los
|
||||
valores de audio `a.fft[0]` a `a.fft[4]` y la variable `bpm`. En Shaders el
|
||||
código es GLSL y tienes los uniforms `u_time`, `u_bass`, `u_mid`, `u_treble`,
|
||||
`u_bpm`, `u_beat` y la textura `u_fft`.
|
||||
|
||||
## El modo Mezclador
|
||||
|
||||
Primero copia tus clips de vídeo en la carpeta `data/videos` del proyecto.
|
||||
Aparecen solos en el desplegable de vídeo del panel. Para que vayan finos en
|
||||
la Raspberry conviene que sean clips cortos, en 720p o menos y en H.264.
|
||||
|
||||
En el panel eliges la fuente: solo la cámara, solo el vídeo, o la mezcla de
|
||||
las dos. Debajo tienes el modo de mezcla y una fila de controles de color:
|
||||
tono, saturación, contraste, brillo, colorama, posterizado, pixelado,
|
||||
caleidoscopio, rotación y feedback. La casilla de pulso al ritmo hace que la
|
||||
imagen lata con los graves.
|
||||
|
||||
## Apagar y reiniciar
|
||||
|
||||
Abajo del todo están los botones para reiniciar y apagar la Raspberry. Piden
|
||||
confirmación. Apagar desde aquí es la forma correcta de apagar la placa al
|
||||
terminar, mejor que cortar la corriente.
|
||||
290
install.sh
Executable file
290
install.sh
Executable file
|
|
@ -0,0 +1,290 @@
|
|||
#!/usr/bin/env bash
|
||||
# ===========================================================================
|
||||
# FOSFENO :: instalador para Raspberry Pi OS Bookworm (Raspberry Pi 4 y 5)
|
||||
#
|
||||
# Uso: bash install.sh # instala todo (incluido projectM)
|
||||
# bash install.sh --no-projectm # omite la compilacion de projectM
|
||||
# bash install.sh --check # solo comprueba el sistema, no instala
|
||||
# ===========================================================================
|
||||
set -uo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "$DIR/scripts/lib.sh"
|
||||
|
||||
MODE="install"
|
||||
SKIP_PM="no"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-projectm) SKIP_PM="yes" ;;
|
||||
--check) MODE="check" ;;
|
||||
*) echo "Opcion desconocida: $arg"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Versiones minimas requeridas
|
||||
MIN_PYTHON="3.9"
|
||||
MIN_NODE="16"
|
||||
MIN_NPM="8"
|
||||
MIN_CMAKE="3.21"
|
||||
|
||||
printf '%s\n' "$C_BLD==================================================="
|
||||
printf '%s\n' " FOSFENO :: instalador ($DIR)"
|
||||
printf '%s\n' "===================================================$C_RST"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Comprobacion del hardware y del sistema operativo
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[1/9] Comprobando hardware y sistema operativo"
|
||||
|
||||
MODELO="$(pi_model)"
|
||||
log_info "Modelo detectado: $MODELO"
|
||||
case "$MODELO" in
|
||||
*"Raspberry Pi 5"*) log_ok "Raspberry Pi 5 (soportada)" ;;
|
||||
*"Raspberry Pi 4"*) log_ok "Raspberry Pi 4 (soportada)" ;;
|
||||
*"Raspberry Pi"*) warn "Raspberry Pi distinta de 4/5: puede funcionar pero sin garantias" ;;
|
||||
*) warn "No parece una Raspberry Pi: continuando bajo tu responsabilidad" ;;
|
||||
esac
|
||||
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
log_info "Sistema: ${PRETTY_NAME:-desconocido}"
|
||||
if [ "${VERSION_CODENAME:-}" = "bookworm" ]; then
|
||||
log_ok "Raspberry Pi OS Bookworm"
|
||||
else
|
||||
warn "Se recomienda Raspberry Pi OS Bookworm (detectado: ${VERSION_CODENAME:-?})"
|
||||
fi
|
||||
fi
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
log_info "Arquitectura: $ARCH"
|
||||
[ "$ARCH" = "aarch64" ] && log_ok "Sistema de 64 bits" \
|
||||
|| warn "Se recomienda Raspberry Pi OS de 64 bits para mejor rendimiento"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modo --check: solo verifica lo que ya esta instalado y termina
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "$MODE" = "check" ]; then
|
||||
log_step "Comprobando herramientas ya instaladas"
|
||||
require_version "Python" "python3 --version" "$MIN_PYTHON" || true
|
||||
require_version "Node.js" "node --version" "$MIN_NODE" || true
|
||||
require_version "npm" "npm --version" "$MIN_NPM" || true
|
||||
require_version "CMake" "cmake --version" "$MIN_CMAKE" || true
|
||||
if need_cmd chromium-browser || need_cmd chromium; then
|
||||
log_ok "Chromium presente"
|
||||
else
|
||||
log_fail "Chromium no encontrado"
|
||||
fi
|
||||
need_cmd git && log_ok "git presente" || log_fail "git no encontrado"
|
||||
need_cmd projectMSDL && log_ok "projectM (projectMSDL) presente" \
|
||||
|| log_warn "projectM no instalado (opcional)"
|
||||
printf '\n'
|
||||
[ "$FOSFENO_WARNINGS" -eq 0 ] && log_ok "Comprobacion terminada sin avisos" \
|
||||
|| log_warn "Comprobacion terminada con $FOSFENO_WARNINGS aviso(s)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Paquetes del sistema
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[2/9] Instalando dependencias del sistema (apt)"
|
||||
sudo apt-get update
|
||||
if sudo apt-get install -y \
|
||||
python3 python3-venv python3-pip \
|
||||
chromium-browser \
|
||||
nodejs npm \
|
||||
git cmake build-essential pkg-config \
|
||||
libsdl2-dev libgles2-mesa-dev mesa-common-dev libglm-dev libpoco-dev \
|
||||
pulseaudio-utils \
|
||||
v4l-utils \
|
||||
avahi-daemon \
|
||||
xdotool unclutter; then
|
||||
log_ok "Paquetes apt instalados"
|
||||
else
|
||||
log_fail "Fallo al instalar paquetes apt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Verificacion de versiones de las herramientas
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[3/9] Verificando versiones de las herramientas"
|
||||
require_version "Python" "python3 --version" "$MIN_PYTHON" \
|
||||
|| { log_fail "Python demasiado antiguo; aborto"; exit 1; }
|
||||
require_version "Node.js" "node --version" "$MIN_NODE" \
|
||||
|| warn "Node.js antiguo: 'npm install' podria fallar"
|
||||
require_version "npm" "npm --version" "$MIN_NPM" || true
|
||||
if [ "$SKIP_PM" = "no" ]; then
|
||||
require_version "CMake" "cmake --version" "$MIN_CMAKE" \
|
||||
|| { warn "CMake antiguo: projectM no se compilara"; SKIP_PM="yes"; }
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Entorno Python
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[4/9] Creando el entorno virtual de Python"
|
||||
python3 -m venv "$DIR/.venv"
|
||||
"$DIR/.venv/bin/pip" install --quiet --upgrade pip
|
||||
"$DIR/.venv/bin/pip" install --quiet -r "$DIR/backend/requirements.txt"
|
||||
if "$DIR/.venv/bin/python3" -c "import flask, flask_socketio" 2>/dev/null; then
|
||||
log_ok "Entorno Python listo (flask, flask-socketio)"
|
||||
else
|
||||
log_fail "Las dependencias de Python no se importan correctamente"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Librerias web (Butterchurn, Hydra, Socket.IO, CodeMirror)
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[5/9] Descargando librerias de visuales (npm)"
|
||||
cd "$DIR/web"
|
||||
if npm install --no-audit --no-fund --loglevel=error; then
|
||||
log_ok "Paquetes npm descargados"
|
||||
else
|
||||
log_fail "Fallo en 'npm install'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$DIR/web/lib/codemirror"
|
||||
# Copia un fichero y comprueba que existe y no esta vacio
|
||||
copy_lib() { # copy_lib <origen> <destino> <descripcion>
|
||||
if cp "$1" "$2" 2>/dev/null && [ -s "$2" ]; then
|
||||
log_ok "$3"
|
||||
else
|
||||
warn "no se pudo copiar: $3 ($1)"
|
||||
fi
|
||||
}
|
||||
copy_lib node_modules/butterchurn/dist/butterchurn.min.js \
|
||||
lib/butterchurn.min.js "Butterchurn"
|
||||
copy_lib node_modules/butterchurn-presets/dist/base.min.js \
|
||||
lib/butterchurn-presets.min.js "Presets de Butterchurn"
|
||||
copy_lib node_modules/socket.io-client/dist/socket.io.min.js \
|
||||
lib/socket.io.min.js "Socket.IO"
|
||||
copy_lib node_modules/qrcode-generator/qrcode.js \
|
||||
lib/qrcode.js "Generador de codigo QR"
|
||||
copy_lib node_modules/codemirror/lib/codemirror.js \
|
||||
lib/codemirror/codemirror.js "CodeMirror (editor)"
|
||||
copy_lib node_modules/codemirror/lib/codemirror.css \
|
||||
lib/codemirror/codemirror.css "CodeMirror (estilos)"
|
||||
copy_lib node_modules/codemirror/mode/javascript/javascript.js \
|
||||
lib/codemirror/javascript.js "CodeMirror (modo JavaScript)"
|
||||
copy_lib node_modules/codemirror/mode/clike/clike.js \
|
||||
lib/codemirror/clike.js "CodeMirror (modo GLSL)"
|
||||
copy_lib node_modules/codemirror/theme/material-darker.css \
|
||||
lib/codemirror/material-darker.css "CodeMirror (tema)"
|
||||
# Hydra no siempre ubica el build en el mismo sitio: probamos varias rutas
|
||||
if cp node_modules/hydra-synth/dist/hydra-synth.js lib/hydra-synth.js 2>/dev/null && [ -s lib/hydra-synth.js ]; then
|
||||
log_ok "Hydra"
|
||||
elif cp node_modules/hydra-synth/build/hydra-synth.js lib/hydra-synth.js 2>/dev/null && [ -s lib/hydra-synth.js ]; then
|
||||
log_ok "Hydra (build/)"
|
||||
else
|
||||
warn "no se encontro el build de hydra-synth en node_modules; revisa la ruta"
|
||||
fi
|
||||
cd "$DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. projectM nativo (compilado desde fuente)
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[6/9] projectM (visualizador nativo)"
|
||||
if [ "$SKIP_PM" = "yes" ]; then
|
||||
log_info "projectM omitido"
|
||||
elif command -v projectMSDL >/dev/null 2>&1; then
|
||||
log_ok "projectM ya estaba instalado ($(command -v projectMSDL))"
|
||||
else
|
||||
log_info "Compilando projectM desde fuente (puede tardar 10-20 min en una Pi)..."
|
||||
if bash "$DIR/scripts/build-projectm.sh"; then
|
||||
command -v projectMSDL >/dev/null 2>&1 \
|
||||
&& log_ok "projectM compilado e instalado" \
|
||||
|| warn "projectM compilo pero 'projectMSDL' no esta en el PATH"
|
||||
else
|
||||
warn "projectM no se pudo compilar; FOSFENO seguira con Butterchurn, Hydra y Shaders"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Presets de projectM y carpeta de videos
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[7/9] Recursos (presets de projectM, carpeta de videos)"
|
||||
mkdir -p "$DIR/data/presets-projectm" "$DIR/data/videos"
|
||||
if [ -z "$(ls -A "$DIR/data/presets-projectm" 2>/dev/null)" ]; then
|
||||
if git clone --depth 1 \
|
||||
https://github.com/projectM-visualizer/presets-cream-of-the-crop.git \
|
||||
"$DIR/data/presets-projectm" 2>/dev/null; then
|
||||
N=$(find "$DIR/data/presets-projectm" -name '*.milk' | wc -l)
|
||||
log_ok "Presets de projectM descargados ($N presets)"
|
||||
else
|
||||
warn "no se pudieron descargar los presets de projectM"
|
||||
fi
|
||||
else
|
||||
log_ok "Presets de projectM ya presentes"
|
||||
fi
|
||||
log_info "Copia tus clips .mp4 en: $DIR/data/videos/"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Arranque automatico y permisos
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[8/9] Configurando arranque automatico y permisos"
|
||||
chmod +x "$DIR/scripts/"*.sh "$DIR/install.sh" "$DIR/uninstall.sh"
|
||||
mkdir -p "$HOME/.config/autostart"
|
||||
if sed "s#__DIR__#$DIR#g" "$DIR/scripts/fosfeno-autostart.desktop" \
|
||||
> "$HOME/.config/autostart/fosfeno.desktop"; then
|
||||
log_ok "Arranque automatico configurado (~/.config/autostart/fosfeno.desktop)"
|
||||
fi
|
||||
|
||||
# Nombre de red fijo: deja el panel accesible en http://<hostname>.local/
|
||||
HOSTNAME_WANT="$(python3 -c "import json;print(json.load(open('$DIR/config.json')).get('network',{}).get('hostname','fosfeno'))" 2>/dev/null || echo fosfeno)"
|
||||
if [ "$(hostname)" != "$HOSTNAME_WANT" ]; then
|
||||
sudo hostnamectl set-hostname "$HOSTNAME_WANT" 2>/dev/null || true
|
||||
if grep -q "^127.0.1.1" /etc/hosts; then
|
||||
sudo sed -i "s/^127.0.1.1.*/127.0.1.1\t$HOSTNAME_WANT/" /etc/hosts
|
||||
else
|
||||
echo -e "127.0.1.1\t$HOSTNAME_WANT" | sudo tee -a /etc/hosts >/dev/null
|
||||
fi
|
||||
log_ok "Nombre de red puesto a '$HOSTNAME_WANT' (panel en http://$HOSTNAME_WANT.local/)"
|
||||
else
|
||||
log_ok "Nombre de red: $HOSTNAME_WANT"
|
||||
fi
|
||||
|
||||
if echo "$USER ALL=(ALL) NOPASSWD: /sbin/reboot, /sbin/poweroff" \
|
||||
| sudo tee /etc/sudoers.d/fosfeno >/dev/null \
|
||||
&& sudo chmod 440 /etc/sudoers.d/fosfeno; then
|
||||
log_ok "Permisos de reinicio/apagado configurados"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Permiso para el puerto 80
|
||||
# ---------------------------------------------------------------------------
|
||||
log_step "[9/9] Permitiendo a Python escuchar en el puerto 80"
|
||||
PYBIN="$(readlink -f "$DIR/.venv/bin/python3")"
|
||||
if sudo setcap 'cap_net_bind_service=+ep' "$PYBIN" 2>/dev/null; then
|
||||
log_ok "Puerto 80 habilitado"
|
||||
else
|
||||
warn "no se pudo habilitar el puerto 80; cambia 'server.port' a 8080 en config.json"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resumen final
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n%s\n' "$C_BLD==================================================="
|
||||
if [ "$FOSFENO_WARNINGS" -eq 0 ]; then
|
||||
log_ok "FOSFENO instalado correctamente, sin avisos."
|
||||
else
|
||||
log_warn "FOSFENO instalado con $FOSFENO_WARNINGS aviso(s) (revisa arriba)."
|
||||
fi
|
||||
printf '%s\n' "===================================================$C_RST"
|
||||
cat <<EOF
|
||||
|
||||
Siguientes pasos:
|
||||
1. Activa el login automatico al escritorio:
|
||||
sudo raspi-config -> System Options -> Boot / Auto Login
|
||||
-> Desktop Autologin
|
||||
2. Reinicia: sudo reboot
|
||||
|
||||
Como entrar en el panel de control:
|
||||
- Al arrancar, el proyector muestra un codigo QR y la direccion.
|
||||
Escanea el QR con el movil y se abre el panel. Mas facil imposible.
|
||||
- Si prefieres escribirla: http://$HOSTNAME_WANT.local/
|
||||
- El movil debe estar en la misma red (WiFi o cable) que la Raspberry.
|
||||
|
||||
Para volver a comprobar el sistema sin reinstalar:
|
||||
bash install.sh --check
|
||||
EOF
|
||||
29
scripts/build-projectm.sh
Executable file
29
scripts/build-projectm.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
# Compila projectM v4 + su frontend SDL2 (projectMSDL) desde el codigo fuente.
|
||||
# Lo invoca install.sh. Tarda bastante en una Raspberry Pi.
|
||||
set -e
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
cd "$WORK"
|
||||
|
||||
echo "==> [projectM] Clonando y compilando la libreria projectM v4..."
|
||||
git clone --depth 1 --recurse-submodules \
|
||||
https://github.com/projectM-visualizer/projectm.git
|
||||
cd projectm
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_INSTALL_PREFIX=/usr/local -DENABLE_GLES=ON
|
||||
cmake --build build --parallel "$(nproc)"
|
||||
sudo cmake --install build
|
||||
sudo ldconfig
|
||||
|
||||
cd "$WORK"
|
||||
echo "==> [projectM] Clonando y compilando el frontend SDL2 (projectMSDL)..."
|
||||
git clone --depth 1 --recurse-submodules \
|
||||
https://github.com/projectM-visualizer/frontend-sdl2.git
|
||||
cd frontend-sdl2
|
||||
cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local
|
||||
cmake --build build --parallel "$(nproc)"
|
||||
sudo cmake --install build
|
||||
|
||||
echo "==> [projectM] Instalado correctamente."
|
||||
7
scripts/fosfeno-autostart.desktop
Normal file
7
scripts/fosfeno-autostart.desktop
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=FOSFENO
|
||||
Comment=Motor de visuales audio-reactivas
|
||||
Exec=__DIR__/scripts/start-fosfeno.sh
|
||||
X-GNOME-Autostart-enabled=true
|
||||
NoDisplay=true
|
||||
71
scripts/lib.sh
Executable file
71
scripts/lib.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env bash
|
||||
# ===========================================================================
|
||||
# FOSFENO :: lib.sh - funciones comunes para los scripts de instalacion
|
||||
# Se carga con: source "$(dirname "$0")/scripts/lib.sh"
|
||||
# ===========================================================================
|
||||
|
||||
# --- Colores (se desactivan si la salida no es un terminal) ----------------
|
||||
if [ -t 1 ]; then
|
||||
C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'
|
||||
C_CYA=$'\e[36m'; C_BLD=$'\e[1m'; C_RST=$'\e[0m'
|
||||
else
|
||||
C_RED=''; C_GRN=''; C_YEL=''; C_CYA=''; C_BLD=''; C_RST=''
|
||||
fi
|
||||
|
||||
# --- Logging ----------------------------------------------------------------
|
||||
log_step() { printf '\n%s==> %s%s\n' "$C_CYA$C_BLD" "$*" "$C_RST"; }
|
||||
log_ok() { printf ' %s[ OK ]%s %s\n' "$C_GRN" "$C_RST" "$*"; }
|
||||
log_warn() { printf ' %s[ !! ]%s %s\n' "$C_YEL" "$C_RST" "$*"; }
|
||||
log_fail() { printf ' %s[ XX ]%s %s\n' "$C_RED" "$C_RST" "$*"; }
|
||||
log_info() { printf ' %s-%s %s\n' "$C_CYA" "$C_RST" "$*"; }
|
||||
|
||||
# Contador global de errores no fatales
|
||||
FOSFENO_WARNINGS=0
|
||||
warn() { log_warn "$*"; FOSFENO_WARNINGS=$((FOSFENO_WARNINGS + 1)); }
|
||||
|
||||
# --- Versiones --------------------------------------------------------------
|
||||
# ver_of "salida cualquiera v1.2.3 bla" -> "1.2.3"
|
||||
ver_of() { printf '%s' "$1" | grep -oE '[0-9]+(\.[0-9]+){1,2}' | head -n1; }
|
||||
|
||||
# ver_ge "1.2.3" "1.2.0" -> exit 0 si $1 >= $2
|
||||
ver_ge() {
|
||||
[ "$1" = "$2" ] && return 0
|
||||
local menor
|
||||
menor=$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)
|
||||
[ "$menor" = "$2" ]
|
||||
}
|
||||
|
||||
# require_version <nombre> <comando --version> <version_minima>
|
||||
# Comprueba que un comando existe y cumple la version minima.
|
||||
require_version() {
|
||||
local nombre="$1" cmd="$2" minima="$3" salida actual
|
||||
if ! command -v "${cmd%% *}" >/dev/null 2>&1; then
|
||||
log_fail "$nombre no esta instalado"
|
||||
return 1
|
||||
fi
|
||||
salida=$($cmd 2>&1 | head -n3)
|
||||
actual=$(ver_of "$salida")
|
||||
if [ -z "$actual" ]; then
|
||||
warn "$nombre instalado pero no se pudo leer la version"
|
||||
return 0
|
||||
fi
|
||||
if ver_ge "$actual" "$minima"; then
|
||||
log_ok "$nombre $actual (minimo $minima)"
|
||||
return 0
|
||||
else
|
||||
log_fail "$nombre $actual es anterior al minimo requerido $minima"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Hardware ---------------------------------------------------------------
|
||||
pi_model() {
|
||||
if [ -r /proc/device-tree/model ]; then
|
||||
tr -d '\0' < /proc/device-tree/model
|
||||
else
|
||||
echo "desconocido"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Utilidades -------------------------------------------------------------
|
||||
need_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
17
scripts/start-fosfeno.sh
Executable file
17
scripts/start-fosfeno.sh
Executable file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# Arranque de FOSFENO. Lo ejecuta el escritorio de Raspberry Pi OS al iniciar
|
||||
# (ver scripts/fosfeno-autostart.desktop, copiado a ~/.config/autostart/).
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Oculta el cursor del raton tras 1s de inactividad
|
||||
command -v unclutter >/dev/null 2>&1 && unclutter -idle 1 &
|
||||
|
||||
# Evita que la pantalla se apague o entre en ahorro de energia (sesion X11)
|
||||
xset s off 2>/dev/null
|
||||
xset -dpms 2>/dev/null
|
||||
xset s noblank 2>/dev/null
|
||||
|
||||
# Espera a que la red este lista (para que el panel sea accesible)
|
||||
sleep 5
|
||||
|
||||
exec "$DIR/.venv/bin/python3" "$DIR/backend/server.py"
|
||||
13
scripts/start-projectm.sh
Executable file
13
scripts/start-projectm.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
|||
#!/usr/bin/env bash
|
||||
# Lanza projectM nativo a pantalla completa. Lo invoca el servidor cuando
|
||||
# se selecciona el motor "projectM" desde el panel.
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PRESETS="$DIR/data/presets-projectm"
|
||||
|
||||
# projectMSDL (frontend SDL2 de projectM v4). La ruta de presets se puede
|
||||
# fijar tambien en ~/.config/projectMSDL/projectMSDL.properties
|
||||
if [ -d "$PRESETS" ]; then
|
||||
exec projectMSDL --presetPath "$PRESETS"
|
||||
else
|
||||
exec projectMSDL
|
||||
fi
|
||||
16
uninstall.sh
Executable file
16
uninstall.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# Desinstala FOSFENO (no borra el repositorio ni el codigo).
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "==> Eliminando el arranque automatico..."
|
||||
rm -f "$HOME/.config/autostart/fosfeno.desktop"
|
||||
|
||||
echo "==> Eliminando los permisos de sudo..."
|
||||
sudo rm -f /etc/sudoers.d/fosfeno
|
||||
|
||||
echo "==> Cerrando procesos en marcha..."
|
||||
pkill -f "backend/server.py" 2>/dev/null || true
|
||||
pkill -f "projectMSDL" 2>/dev/null || true
|
||||
|
||||
echo "==> FOSFENO desinstalado. El codigo sigue en $DIR"
|
||||
echo " Para borrarlo todo: rm -rf $DIR/.venv $DIR/web/node_modules $DIR/web/lib"
|
||||
14
web/package.json
Normal file
14
web/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "fosfeno-web",
|
||||
"version": "1.0.0",
|
||||
"description": "Librerias del escenario de visuales de FOSFENO",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"codemirror": "^5.65.16",
|
||||
"hydra-synth": "^1.3.29",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"socket.io-client": "^4.7.5"
|
||||
}
|
||||
}
|
||||
201
web/panel/index.html
Normal file
201
web/panel/index.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FOSFENO :: Panel</title>
|
||||
<link rel="stylesheet" href="/panel/panel.css">
|
||||
<link rel="stylesheet" href="/lib/codemirror/codemirror.css">
|
||||
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>F O S F E N O</h1>
|
||||
<span id="conn" class="dot off" title="Conexion"></span>
|
||||
</header>
|
||||
|
||||
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
|
||||
<div id="notif" class="notif" hidden>
|
||||
<span id="notif-msg"></span>
|
||||
<button id="notif-close" aria-label="Cerrar aviso">×</button>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<!-- Encendido -->
|
||||
<section class="card center">
|
||||
<div class="cardhead">
|
||||
<span class="label">Visuales</span>
|
||||
<button class="info" data-help="power">i</button>
|
||||
</div>
|
||||
<button id="power" class="power-btn">ON</button>
|
||||
</section>
|
||||
|
||||
<!-- Selector de motor -->
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<span class="label">Motor de visuales</span>
|
||||
<button class="info" data-help="engines">i</button>
|
||||
</div>
|
||||
<div class="engines">
|
||||
<button class="engine" data-engine="projectm">
|
||||
<strong>projectM</strong><small>nativo</small></button>
|
||||
<button class="engine" data-engine="butterchurn">
|
||||
<strong>Butter</strong><small>MilkDrop</small></button>
|
||||
<button class="engine" data-engine="hydra">
|
||||
<strong>Hydra</strong><small>codigo</small></button>
|
||||
<button class="engine" data-engine="shaders">
|
||||
<strong>Shaders</strong><small>GLSL</small></button>
|
||||
<button class="engine" data-engine="mixer">
|
||||
<strong>Mezcla</strong><small>camara</small></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio: tarjeta + BPM -->
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<span class="label">Audio</span>
|
||||
<button class="info" data-help="audio">i</button>
|
||||
</div>
|
||||
<select id="audio-device"><option>Detectando entradas...</option></select>
|
||||
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
|
||||
<div class="row">
|
||||
<span class="label">BPM detectado</span>
|
||||
<span id="bpm" class="bpm">--</span>
|
||||
</div>
|
||||
<p class="hint">Si conectas el microfono o la camara con la Raspberry ya
|
||||
encendida, pulsa este boton para que aparezcan.</p>
|
||||
</section>
|
||||
|
||||
<!-- Sensibilidad -->
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<span class="label">Sensibilidad al audio:
|
||||
<span id="sens-val" class="value">1.0</span></span>
|
||||
<button class="info" data-help="sensibilidad">i</button>
|
||||
</div>
|
||||
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
|
||||
</section>
|
||||
|
||||
<!-- Butterchurn -->
|
||||
<section class="card" id="ctl-butterchurn" hidden>
|
||||
<div class="cardhead">
|
||||
<span class="label">Butterchurn</span>
|
||||
<button class="info" data-help="butterchurn">i</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="cmd" data-action="prev">« Anterior</button>
|
||||
<button class="cmd" data-action="next">Siguiente »</button>
|
||||
</div>
|
||||
<select id="bc-preset"><option>Cargando presets...</option></select>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="bc-shuffle"> Cambio automatico de preset
|
||||
</label>
|
||||
<div class="seg">
|
||||
<button id="bc-mode-seconds" class="segbtn">Por segundos</button>
|
||||
<button id="bc-mode-beats" class="segbtn">Al compas (BPM)</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">Intervalo: <span id="bc-int-val">20</span>
|
||||
<span id="bc-int-unit">s</span></span>
|
||||
</div>
|
||||
<input id="bc-interval" type="range" min="1" max="120" step="1" value="20">
|
||||
<div class="row">
|
||||
<span class="label">Transicion: <span id="bc-blend-val">2.7</span> s</span>
|
||||
</div>
|
||||
<input id="bc-blend" type="range" min="0" max="10" step="0.1" value="2.7">
|
||||
</section>
|
||||
|
||||
<!-- Editor de codigo (Hydra / Shaders) -->
|
||||
<section class="card" id="ctl-editor" hidden>
|
||||
<div class="cardhead">
|
||||
<span class="label" id="editor-title">Editor de codigo</span>
|
||||
<button class="info" id="editor-info" data-help="hydra">i</button>
|
||||
</div>
|
||||
<select id="lib-select"><option>Libreria...</option></select>
|
||||
<textarea id="code"></textarea>
|
||||
<div class="row">
|
||||
<button id="run-code" class="cmd accent">Ejecutar</button>
|
||||
<button id="clear-code" class="cmd">Limpiar</button>
|
||||
</div>
|
||||
<p class="hint" id="editor-hint"></p>
|
||||
</section>
|
||||
|
||||
<!-- Mezclador VJ -->
|
||||
<section class="card" id="ctl-mixer" hidden>
|
||||
<div class="cardhead">
|
||||
<span class="label">Mezclador VJ</span>
|
||||
<button class="info" data-help="mixer">i</button>
|
||||
</div>
|
||||
<div class="seg" id="mixer-source">
|
||||
<button class="segbtn" data-src="cam">Camara</button>
|
||||
<button class="segbtn" data-src="video">Video</button>
|
||||
<button class="segbtn" data-src="mix">Mezcla</button>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-cam"> Camara activada
|
||||
</label>
|
||||
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
|
||||
<select id="mix-video"><option value="">-- sin video --</option></select>
|
||||
<button id="mix-rescan" class="cmd">Actualizar lista de videos</button>
|
||||
<div class="row">
|
||||
<span class="label">Modo de mezcla</span>
|
||||
<select id="mix-blend" class="inline">
|
||||
<option value="blend">Fundido</option>
|
||||
<option value="diff">Diferencia</option>
|
||||
<option value="mult">Multiplicar</option>
|
||||
<option value="add">Sumar</option>
|
||||
<option value="layer">Capa</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="mixer-sliders"></div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-invert"> Invertir colores
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="mix-beat"> Pulso al ritmo
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- projectM -->
|
||||
<section class="card" id="ctl-projectm" hidden>
|
||||
<div class="cardhead">
|
||||
<span class="label">projectM</span>
|
||||
<button class="info" data-help="projectm">i</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="cmd" data-action="prev">« Anterior</button>
|
||||
<button class="cmd" data-action="next">Siguiente »</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Estado -->
|
||||
<section class="card">
|
||||
<span class="label">Ahora suena</span>
|
||||
<div id="now" class="now">-</div>
|
||||
</section>
|
||||
|
||||
<!-- Sistema -->
|
||||
<section class="card sys">
|
||||
<button id="reboot" class="sysbtn">Reiniciar Pi</button>
|
||||
<button id="shutdown" class="sysbtn danger">Apagar Pi</button>
|
||||
</section>
|
||||
|
||||
<p id="net-foot" class="netfoot">FOSFENO</p>
|
||||
</main>
|
||||
|
||||
<!-- Ventana de informacion -->
|
||||
<div id="modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<h2 id="modal-title">Informacion</h2>
|
||||
<div id="modal-body"></div>
|
||||
<button id="modal-close" class="cmd accent">Entendido</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/lib/socket.io.min.js"></script>
|
||||
<script src="/lib/codemirror/codemirror.js"></script>
|
||||
<script src="/lib/codemirror/javascript.js"></script>
|
||||
<script src="/lib/codemirror/clike.js"></script>
|
||||
<script src="/panel/panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
212
web/panel/panel.css
Normal file
212
web/panel/panel.css
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/* FOSFENO :: Panel de control
|
||||
Tema verde acido y negro, con acentos en naranja neon. */
|
||||
:root {
|
||||
--bg: #060a06;
|
||||
--card: #0d120c;
|
||||
--line: #243016;
|
||||
--green: #b4ff00; /* verde acido */
|
||||
--green-d: #86bd00;
|
||||
--orange: #ff7a00; /* naranja acido */
|
||||
--orange-n: #ff9d2a; /* naranja neon */
|
||||
--ink: #050705; /* negro: texto sobre superficies brillantes */
|
||||
--txt: #cfe8ad; /* texto claro sobre fondo oscuro */
|
||||
--dim: #6f8a52;
|
||||
--red: #ff3b2f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* El atributo 'hidden' debe ocultar siempre, incluso sobre display:flex */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
font-family: "Segoe UI", Roboto, system-ui, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 20px; border-bottom: 2px solid var(--green-d);
|
||||
position: sticky; top: 0; background: var(--bg); z-index: 10;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 20px; font-weight: 800; letter-spacing: 0.32em;
|
||||
color: var(--green); text-shadow: 0 0 14px rgba(180, 255, 0, 0.55);
|
||||
}
|
||||
|
||||
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; }
|
||||
.dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); }
|
||||
.dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); }
|
||||
|
||||
main {
|
||||
max-width: 560px; margin: 0 auto;
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
|
||||
/* --- Tarjetas --- */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 16px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.card.center { align-items: center; }
|
||||
|
||||
.cardhead {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; width: 100%;
|
||||
}
|
||||
|
||||
.label { color: var(--green); font-size: 13px; text-transform: uppercase;
|
||||
letter-spacing: 0.09em; font-weight: 700; }
|
||||
.value { color: var(--orange-n); font-weight: 800; }
|
||||
.hint { color: var(--dim); font-size: 12px; line-height: 1.45; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; }
|
||||
.row .cmd { flex: 1; }
|
||||
|
||||
/* --- Boton de informacion (circular naranja) --- */
|
||||
.info {
|
||||
width: 28px; height: 28px; flex: none; border-radius: 50%;
|
||||
background: var(--orange); color: var(--ink); border: none;
|
||||
font-weight: 900; font-style: italic; font-size: 15px; cursor: pointer;
|
||||
box-shadow: 0 0 10px rgba(255, 122, 0, 0.5);
|
||||
}
|
||||
.info:active { background: var(--orange-n); }
|
||||
|
||||
/* --- Boton de encendido (circular grande) --- */
|
||||
.power-btn {
|
||||
width: 128px; height: 128px; border-radius: 50%;
|
||||
border: none; cursor: pointer; font-weight: 900; font-size: 26px;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--green); color: var(--ink);
|
||||
box-shadow: 0 0 26px rgba(180, 255, 0, 0.55);
|
||||
}
|
||||
.power-btn.off {
|
||||
background: var(--card); color: var(--dim);
|
||||
border: 3px solid var(--line); box-shadow: none;
|
||||
}
|
||||
|
||||
/* --- Selector de motor (botones circulares) --- */
|
||||
.engines {
|
||||
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
|
||||
}
|
||||
.engine {
|
||||
width: 96px; height: 96px; border-radius: 50%;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 2px; cursor: pointer;
|
||||
background: var(--card); border: 3px solid var(--line); color: var(--txt);
|
||||
}
|
||||
.engine strong { font-size: 13px; }
|
||||
.engine small { font-size: 9px; color: var(--dim); }
|
||||
.engine.active {
|
||||
background: var(--green); color: var(--ink); border-color: var(--orange);
|
||||
box-shadow: 0 0 20px rgba(180, 255, 0, 0.6);
|
||||
}
|
||||
.engine.active small { color: var(--ink); }
|
||||
|
||||
/* --- Botones de comando --- */
|
||||
.cmd, .sysbtn {
|
||||
padding: 13px; border-radius: 11px; cursor: pointer;
|
||||
background: #161d10; border: 1px solid var(--line); color: var(--txt);
|
||||
font-weight: 700; font-size: 14px;
|
||||
}
|
||||
.cmd:active, .sysbtn:active { background: #202a16; }
|
||||
.cmd.accent {
|
||||
background: var(--green); color: var(--ink); border: none;
|
||||
}
|
||||
|
||||
/* --- Selectores y sliders --- */
|
||||
select {
|
||||
width: 100%; padding: 12px; border-radius: 11px;
|
||||
background: #161d10; color: var(--txt); border: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
}
|
||||
select.inline { width: auto; padding: 8px 10px; }
|
||||
input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
|
||||
|
||||
.check { display: flex; align-items: center; gap: 10px; font-size: 14px;
|
||||
color: var(--txt); }
|
||||
.check input { width: 22px; height: 22px; accent-color: var(--orange); }
|
||||
|
||||
/* --- Botones segmentados --- */
|
||||
.seg { display: flex; gap: 6px; }
|
||||
.segbtn {
|
||||
flex: 1; padding: 11px 6px; border-radius: 10px; cursor: pointer;
|
||||
background: #161d10; border: 2px solid var(--line); color: var(--dim);
|
||||
font-weight: 700; font-size: 13px;
|
||||
}
|
||||
.segbtn.active {
|
||||
border-color: var(--orange); color: var(--orange-n);
|
||||
box-shadow: 0 0 12px rgba(255, 122, 0, 0.35);
|
||||
}
|
||||
|
||||
/* --- BPM --- */
|
||||
.bpm {
|
||||
font-size: 30px; font-weight: 900; color: var(--orange-n);
|
||||
text-shadow: 0 0 14px rgba(255, 157, 42, 0.6);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- Sliders del mezclador --- */
|
||||
.slider { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
|
||||
.slider .head {
|
||||
display: flex; justify-content: space-between; font-size: 13px;
|
||||
color: var(--dim);
|
||||
}
|
||||
.slider .head b { color: var(--green); font-weight: 800; }
|
||||
|
||||
/* --- Editor CodeMirror --- */
|
||||
.CodeMirror {
|
||||
height: 220px; border-radius: 11px; border: 1px solid var(--green-d);
|
||||
font-size: 13px; font-family: "Fira Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.now { font-size: 15px; color: var(--orange-n); word-break: break-word; }
|
||||
|
||||
/* --- Sistema --- */
|
||||
.sys { flex-direction: row; }
|
||||
.sys .sysbtn { flex: 1; }
|
||||
.sysbtn.danger { color: var(--red); border-color: var(--red); }
|
||||
|
||||
/* --- Banda de avisos --- */
|
||||
.notif {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; font-size: 14px; font-weight: 600;
|
||||
position: sticky; top: 60px; z-index: 9;
|
||||
}
|
||||
.notif.info { background: var(--green); color: var(--ink); }
|
||||
.notif.warn { background: var(--orange); color: var(--ink); }
|
||||
.notif.error { background: var(--red); color: #fff; }
|
||||
.notif span { flex: 1; }
|
||||
.notif button {
|
||||
background: transparent; border: none; color: inherit;
|
||||
font-size: 22px; font-weight: 900; cursor: pointer; line-height: 1;
|
||||
}
|
||||
|
||||
/* --- Ventana de informacion --- */
|
||||
.modal {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
background: rgba(3, 6, 3, 0.88);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--card); border: 2px solid var(--green);
|
||||
border-radius: 16px; padding: 22px; max-width: 460px; width: 100%;
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
box-shadow: 0 0 30px rgba(180, 255, 0, 0.3);
|
||||
}
|
||||
.modal-box h2 {
|
||||
color: var(--green); font-size: 18px; letter-spacing: 0.04em;
|
||||
}
|
||||
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
|
||||
|
||||
/* Pie con la direccion del panel */
|
||||
.netfoot {
|
||||
text-align: center; color: var(--dim);
|
||||
font-size: 12px; font-family: monospace; padding: 4px 0 8px;
|
||||
}
|
||||
409
web/panel/panel.js
Normal file
409
web/panel/panel.js
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/*
|
||||
* FOSFENO :: Panel de control
|
||||
* Conecta con el servidor, refleja el estado y envia ordenes.
|
||||
* Incluye editor de codigo, mezclador VJ, ventana de ayuda y avisos.
|
||||
*/
|
||||
|
||||
const socket = io();
|
||||
let state = null;
|
||||
let editorEngine = ""; // que motor tiene cargado el editor
|
||||
let helpData = {}; // textos de ayuda
|
||||
let lastNotifT = 0; // marca de tiempo del ultimo aviso mostrado
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => document.querySelectorAll(s);
|
||||
|
||||
// Sliders del mezclador VJ (se generan dinamicamente)
|
||||
const MIXER_SLIDERS = [
|
||||
{ key: "mix", label: "Mezcla A/B", min: 0, max: 1, step: 0.01 },
|
||||
{ key: "hue", label: "Tono (hue)", min: 0, max: 1, step: 0.01 },
|
||||
{ key: "saturate", label: "Saturacion", min: 0, max: 3, step: 0.05 },
|
||||
{ key: "contrast", label: "Contraste", min: 0, max: 3, step: 0.05 },
|
||||
{ key: "brightness", label: "Brillo", min: -0.5, max: 0.5, step: 0.02 },
|
||||
{ key: "colorama", label: "Colorama", min: 0, max: 1, step: 0.01 },
|
||||
{ key: "posterize", label: "Posterizar", min: 0, max: 8, step: 1 },
|
||||
{ key: "pixelate", label: "Pixelado", min: 0, max: 1, step: 0.02 },
|
||||
{ key: "kaleid", label: "Caleidoscopio", min: 0, max: 12, step: 1 },
|
||||
{ key: "rotate", label: "Rotacion", min: -3, max: 3, step: 0.05 },
|
||||
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01 },
|
||||
];
|
||||
|
||||
// ==========================================================================
|
||||
// Editor de codigo (CodeMirror)
|
||||
// ==========================================================================
|
||||
let editor = null;
|
||||
if (typeof CodeMirror !== "undefined") {
|
||||
editor = CodeMirror.fromTextArea($("#code"), {
|
||||
lineNumbers: true, theme: "material-darker",
|
||||
mode: "javascript", lineWrapping: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Librerias de codigo y de ayuda
|
||||
// ==========================================================================
|
||||
let hydraLib = [];
|
||||
let shaderLib = [];
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const sketches = await fetch("/data/hydra-sketches.json").then((r) => r.json());
|
||||
const snippets = await fetch("/data/hydra-snippets.json").then((r) => r.json());
|
||||
const shaders = await fetch("/data/shaders.json").then((r) => r.json());
|
||||
hydraLib = [];
|
||||
for (const [id, v] of Object.entries(sketches)) {
|
||||
hydraLib.push({ id: "s:" + id, name: "* " + (v.name || id), code: v.code });
|
||||
}
|
||||
for (const [id, v] of Object.entries(snippets.snippets || {})) {
|
||||
hydraLib.push({ id: "x:" + id, name: v.name || id, code: v.code });
|
||||
}
|
||||
shaderLib = Object.entries(shaders).map(
|
||||
([id, v]) => ({ id: id, name: v.name || id, code: v.code }));
|
||||
} catch (err) {
|
||||
console.error("[FOSFENO] No se pudieron cargar las librerias:", err);
|
||||
}
|
||||
try {
|
||||
const ayuda = await fetch("/data/ayuda.json").then((r) => r.json());
|
||||
helpData = ayuda.ayuda || {};
|
||||
} catch (err) {
|
||||
console.error("[FOSFENO] No se pudo cargar la ayuda:", err);
|
||||
}
|
||||
// Si el estado llego antes que las librerias, repinta para que aparezcan
|
||||
if (state) { editorEngine = ""; render(); }
|
||||
}
|
||||
|
||||
function fillLibrarySelect(lib) {
|
||||
const sel = $("#lib-select");
|
||||
sel.innerHTML = '<option value="">-- Cargar de la libreria --</option>';
|
||||
lib.forEach((it, i) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(i);
|
||||
opt.textContent = it.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Ventana de informacion
|
||||
// ==========================================================================
|
||||
function openHelp(key) {
|
||||
const h = helpData[key];
|
||||
if (!h) return;
|
||||
$("#modal-title").textContent = h.title || "Informacion";
|
||||
const body = $("#modal-body");
|
||||
body.innerHTML = "";
|
||||
(h.body || []).forEach((par) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = par;
|
||||
body.appendChild(p);
|
||||
});
|
||||
$("#modal").hidden = false;
|
||||
}
|
||||
|
||||
function closeHelp() { $("#modal").hidden = true; }
|
||||
|
||||
// ==========================================================================
|
||||
// Avisos (banda superior)
|
||||
// ==========================================================================
|
||||
let notifTimer = null;
|
||||
function showNotif(entry) {
|
||||
if (!entry || !entry.message) return;
|
||||
const n = $("#notif");
|
||||
n.className = "notif " + (entry.level || "info");
|
||||
$("#notif-msg").textContent = entry.message;
|
||||
n.hidden = false;
|
||||
clearTimeout(notifTimer);
|
||||
if (entry.level === "info") {
|
||||
notifTimer = setTimeout(() => { n.hidden = true; }, 6000);
|
||||
}
|
||||
if (entry.t) lastNotifT = entry.t;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Sliders del mezclador
|
||||
// ==========================================================================
|
||||
function fmt(v, step) {
|
||||
return step >= 1 ? String(Math.round(v)) : Number(v).toFixed(2);
|
||||
}
|
||||
|
||||
function buildMixerSliders() {
|
||||
const cont = $("#mixer-sliders");
|
||||
MIXER_SLIDERS.forEach((cfg) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "slider";
|
||||
wrap.innerHTML =
|
||||
`<div class="head"><span>${cfg.label}</span>` +
|
||||
`<b id="mv-${cfg.key}">-</b></div>`;
|
||||
const input = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.min = cfg.min; input.max = cfg.max; input.step = cfg.step;
|
||||
input.id = "ms-" + cfg.key;
|
||||
input.addEventListener("input", () => {
|
||||
$("#mv-" + cfg.key).textContent = fmt(input.value, cfg.step);
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const patch = {};
|
||||
patch[cfg.key] = parseFloat(input.value);
|
||||
socket.emit("update_settings", { engine: "mixer", patch: patch });
|
||||
});
|
||||
wrap.appendChild(input);
|
||||
cont.appendChild(wrap);
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
function render() {
|
||||
if (!state) return;
|
||||
$("#conn").className = "dot on";
|
||||
|
||||
const power = $("#power");
|
||||
power.textContent = state.power ? "ON" : "OFF";
|
||||
power.classList.toggle("off", !state.power);
|
||||
|
||||
$$(".engine").forEach((b) =>
|
||||
b.classList.toggle("active", b.dataset.engine === state.engine));
|
||||
|
||||
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
|
||||
(d) => d.id, (d) => d.name);
|
||||
if (state.audio.device) $("#audio-device").value = state.audio.device;
|
||||
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
|
||||
|
||||
$("#sens").value = state.sensitivity;
|
||||
$("#sens-val").textContent = Number(state.sensitivity).toFixed(1);
|
||||
|
||||
$("#ctl-butterchurn").hidden = state.engine !== "butterchurn";
|
||||
$("#ctl-editor").hidden = !(state.engine === "hydra" || state.engine === "shaders");
|
||||
$("#ctl-mixer").hidden = state.engine !== "mixer";
|
||||
$("#ctl-projectm").hidden = state.engine !== "projectm";
|
||||
|
||||
renderButterchurn();
|
||||
renderEditor();
|
||||
renderMixer();
|
||||
|
||||
$("#now").textContent = state.status.label || "-";
|
||||
|
||||
const net = state.network || {};
|
||||
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
||||
$("#net-foot").textContent = "Panel: http://" +
|
||||
(net.hostname || "fosfeno") + ".local" + port + "/" +
|
||||
(net.ip ? " (" + net.ip + ")" : "");
|
||||
|
||||
// Aviso pendiente recibido antes de abrir el panel
|
||||
const notes = state.notifications || [];
|
||||
if (notes.length) {
|
||||
const last = notes[notes.length - 1];
|
||||
if (last.t !== lastNotifT) showNotif(last);
|
||||
}
|
||||
}
|
||||
|
||||
function fillSelectOnce(sel, items, valFn, txtFn) {
|
||||
items = items || [];
|
||||
if (sel.dataset.count == items.length) return;
|
||||
sel.dataset.count = items.length;
|
||||
let keep = "";
|
||||
if (sel.id === "mix-video") {
|
||||
keep = '<option value="">-- sin video --</option>';
|
||||
} else if (sel.id === "mix-camera" && items.length === 0) {
|
||||
keep = '<option value="0">Camara por defecto</option>';
|
||||
}
|
||||
sel.innerHTML = keep;
|
||||
items.forEach((it) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = valFn(it);
|
||||
opt.textContent = txtFn(it);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderButterchurn() {
|
||||
if (state.engine !== "butterchurn") return;
|
||||
const b = state.butterchurn;
|
||||
fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets,
|
||||
(n) => n, (n) => n);
|
||||
if (b.preset) $("#bc-preset").value = b.preset;
|
||||
$("#bc-shuffle").checked = b.shuffle;
|
||||
$("#bc-mode-seconds").classList.toggle("active", b.intervalMode === "seconds");
|
||||
$("#bc-mode-beats").classList.toggle("active", b.intervalMode === "beats");
|
||||
$("#bc-interval").value = b.interval;
|
||||
$("#bc-int-val").textContent = b.interval;
|
||||
$("#bc-int-unit").textContent = b.intervalMode === "beats" ? "comp." : "s";
|
||||
$("#bc-blend").value = b.blendTime;
|
||||
$("#bc-blend-val").textContent = Number(b.blendTime).toFixed(1);
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
if (state.engine !== "hydra" && state.engine !== "shaders") return;
|
||||
$("#editor-info").dataset.help = state.engine;
|
||||
if (state.engine !== editorEngine) {
|
||||
editorEngine = state.engine;
|
||||
const isHydra = state.engine === "hydra";
|
||||
$("#editor-title").textContent = isHydra
|
||||
? "Editor Hydra (JavaScript)" : "Editor de shaders (GLSL)";
|
||||
$("#editor-hint").textContent = isHydra
|
||||
? "Variables: time, a.fft[0..4], bpm. Escribe o pega codigo Hydra."
|
||||
: "Uniforms: u_time, u_bass, u_mid, u_treble, u_bpm, u_beat, u_fft.";
|
||||
fillLibrarySelect(isHydra ? hydraLib : shaderLib);
|
||||
if (editor) {
|
||||
editor.setOption("mode", isHydra ? "javascript" : "text/x-csrc");
|
||||
editor.setValue(state[state.engine].code || "");
|
||||
setTimeout(() => editor.refresh(), 30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderMixer() {
|
||||
if (state.engine !== "mixer") return;
|
||||
const m = state.mixer;
|
||||
$$("#mixer-source .segbtn").forEach((b) =>
|
||||
b.classList.toggle("active", b.dataset.src === m.source));
|
||||
$("#mix-cam").checked = m.camOn;
|
||||
fillSelectOnce($("#mix-camera"), state.meta.cameraDevices,
|
||||
(d) => d.id, (d) => d.name);
|
||||
$("#mix-camera").value = m.cameraId;
|
||||
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
|
||||
(o) => o.v, (o) => o.v);
|
||||
$("#mix-video").value = m.video || "";
|
||||
$("#mix-blend").value = m.blendMode;
|
||||
$("#mix-invert").checked = m.invert;
|
||||
$("#mix-beat").checked = m.beatPulse;
|
||||
MIXER_SLIDERS.forEach((cfg) => {
|
||||
const input = $("#ms-" + cfg.key);
|
||||
if (input && document.activeElement !== input) {
|
||||
input.value = m[cfg.key];
|
||||
$("#mv-" + cfg.key).textContent = fmt(m[cfg.key], cfg.step);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Eventos de la interfaz
|
||||
// ==========================================================================
|
||||
$("#power").addEventListener("click", () =>
|
||||
socket.emit("set_power", { on: !state.power }));
|
||||
|
||||
$$(".engine").forEach((b) =>
|
||||
b.addEventListener("click", () =>
|
||||
socket.emit("set_engine", { engine: b.dataset.engine })));
|
||||
|
||||
$$(".info").forEach((b) =>
|
||||
b.addEventListener("click", () => openHelp(b.dataset.help)));
|
||||
$("#modal-close").addEventListener("click", closeHelp);
|
||||
$("#modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "modal") closeHelp();
|
||||
});
|
||||
|
||||
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
|
||||
|
||||
$("#audio-device").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "audio", patch: { device: e.target.value } }));
|
||||
|
||||
$("#sens").addEventListener("input", (e) =>
|
||||
$("#sens-val").textContent = Number(e.target.value).toFixed(1));
|
||||
$("#sens").addEventListener("change", (e) =>
|
||||
socket.emit("set_sensitivity", { value: parseFloat(e.target.value) }));
|
||||
|
||||
// --- Butterchurn ---
|
||||
$("#bc-preset").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { preset: e.target.value } }));
|
||||
$("#bc-shuffle").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
|
||||
$("#bc-mode-seconds").addEventListener("click", () =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { intervalMode: "seconds" } }));
|
||||
$("#bc-mode-beats").addEventListener("click", () =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { intervalMode: "beats" } }));
|
||||
$("#bc-interval").addEventListener("input", (e) =>
|
||||
$("#bc-int-val").textContent = e.target.value);
|
||||
$("#bc-interval").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { interval: parseInt(e.target.value, 10) } }));
|
||||
$("#bc-blend").addEventListener("input", (e) =>
|
||||
$("#bc-blend-val").textContent = Number(e.target.value).toFixed(1));
|
||||
$("#bc-blend").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "butterchurn", patch: { blendTime: parseFloat(e.target.value) } }));
|
||||
|
||||
// --- Editor de codigo ---
|
||||
$("#run-code").addEventListener("click", () => {
|
||||
if (!editor || !editorEngine) return;
|
||||
socket.emit("run_code",
|
||||
{ engine: editorEngine, code: editor.getValue(), label: "codigo personalizado" });
|
||||
});
|
||||
$("#clear-code").addEventListener("click", () => editor && editor.setValue(""));
|
||||
$("#lib-select").addEventListener("change", (e) => {
|
||||
const lib = editorEngine === "hydra" ? hydraLib : shaderLib;
|
||||
const item = lib[parseInt(e.target.value, 10)];
|
||||
if (!item || !editor) return;
|
||||
editor.setValue(item.code);
|
||||
socket.emit("run_code",
|
||||
{ engine: editorEngine, code: item.code, label: item.name });
|
||||
});
|
||||
|
||||
// --- Mezclador ---
|
||||
$$("#mixer-source .segbtn").forEach((b) =>
|
||||
b.addEventListener("click", () =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { source: b.dataset.src } })));
|
||||
$("#mix-cam").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { camOn: e.target.checked } }));
|
||||
$("#mix-camera").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { cameraId: parseInt(e.target.value, 10) } }));
|
||||
$("#dev-rescan").addEventListener("click", () =>
|
||||
socket.emit("rescan_devices"));
|
||||
$("#mix-video").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { video: e.target.value } }));
|
||||
$("#mix-blend").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { blendMode: e.target.value } }));
|
||||
$("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos"));
|
||||
$("#mix-invert").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { invert: e.target.checked } }));
|
||||
$("#mix-beat").addEventListener("change", (e) =>
|
||||
socket.emit("update_settings",
|
||||
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
|
||||
|
||||
// --- Comandos siguiente/anterior ---
|
||||
$$(".cmd[data-action]").forEach((b) =>
|
||||
b.addEventListener("click", () =>
|
||||
socket.emit("engine_command", { action: b.dataset.action })));
|
||||
|
||||
// --- Sistema ---
|
||||
$("#reboot").addEventListener("click", () => {
|
||||
if (confirm("Reiniciar la Raspberry Pi?")) socket.emit("system", { action: "reboot" });
|
||||
});
|
||||
$("#shutdown").addEventListener("click", () => {
|
||||
if (confirm("Apagar la Raspberry Pi?")) socket.emit("system", { action: "shutdown" });
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// WebSocket
|
||||
// ==========================================================================
|
||||
// Al conectar, el panel se presenta para que el proyector deje de mostrar
|
||||
// la pantalla del codigo QR.
|
||||
socket.on("connect", () => socket.emit("hello", { role: "panel" }));
|
||||
socket.on("state", (next) => { state = next; render(); });
|
||||
socket.on("status", (s) => {
|
||||
if (!state) return;
|
||||
state.status.label = s.label;
|
||||
state.audio.bpm = s.bpm;
|
||||
$("#now").textContent = s.label || "-";
|
||||
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
|
||||
});
|
||||
socket.on("notify", (entry) => showNotif(entry));
|
||||
socket.on("disconnect", () => { $("#conn").className = "dot off"; });
|
||||
|
||||
// ==========================================================================
|
||||
// Arranque
|
||||
// ==========================================================================
|
||||
buildMixerSliders();
|
||||
loadData();
|
||||
69
web/stage/index.html
Normal file
69
web/stage/index.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FOSFENO :: Escenario</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0; padding: 0; width: 100%; height: 100%;
|
||||
background: #000; overflow: hidden; cursor: none;
|
||||
}
|
||||
canvas {
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100vw; height: 100vh; display: none;
|
||||
}
|
||||
canvas.visible { display: block; }
|
||||
#msg {
|
||||
position: absolute; top: 50%; left: 0; right: 0;
|
||||
transform: translateY(-50%); text-align: center;
|
||||
color: #2a2a35; font-family: monospace; font-size: 2vw;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
/* Pantalla de conexion: se muestra hasta que alguien abre el panel */
|
||||
#info {
|
||||
position: absolute; inset: 0; display: none;
|
||||
flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 2.2vh; background: #050805;
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
.info-title {
|
||||
color: #b4ff00; font-size: 5vh; font-weight: 800; letter-spacing: 0.4em;
|
||||
text-shadow: 0 0 24px rgba(180, 255, 0, 0.6);
|
||||
}
|
||||
.info-text { color: #cfe8ad; font-size: 2.8vh; }
|
||||
.info-url {
|
||||
color: #ff9d2a; font-size: 4.4vh; font-weight: 800; font-family: monospace;
|
||||
text-shadow: 0 0 18px rgba(255, 157, 42, 0.5);
|
||||
}
|
||||
.info-qr {
|
||||
width: 34vh; height: 34vh; background: #fff;
|
||||
padding: 1.6vh; border-radius: 1.4vh; image-rendering: pixelated;
|
||||
}
|
||||
.info-sub { color: #6f8a52; font-size: 2.3vh; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="msg">FOSFENO</div>
|
||||
|
||||
<!-- Pantalla de conexion (QR + direccion del panel) -->
|
||||
<div id="info">
|
||||
<div class="info-title">F O S F E N O</div>
|
||||
<div class="info-text">Escanea el codigo con el movil, o abre la direccion:</div>
|
||||
<div class="info-url" id="info-url">buscando direccion...</div>
|
||||
<img id="info-qr" class="info-qr" alt="codigo QR del panel">
|
||||
<div class="info-sub" id="info-sub"></div>
|
||||
</div>
|
||||
|
||||
<canvas id="butterchurn"></canvas>
|
||||
<canvas id="hydra"></canvas>
|
||||
<canvas id="shaders"></canvas>
|
||||
|
||||
<script src="/lib/socket.io.min.js"></script>
|
||||
<script src="/lib/qrcode.js"></script>
|
||||
<script src="/lib/butterchurn.min.js"></script>
|
||||
<script src="/lib/butterchurn-presets.min.js"></script>
|
||||
<script src="/lib/hydra-synth.js"></script>
|
||||
<script src="/stage/stage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
602
web/stage/stage.js
Normal file
602
web/stage/stage.js
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
/*
|
||||
* FOSFENO :: Escenario
|
||||
* Renderiza las visuales en el proyector. Motores:
|
||||
* - butterchurn : presets MilkDrop (WebGL)
|
||||
* - hydra : codigo Hydra en vivo (editor / libreria)
|
||||
* - shaders : shaders GLSL en vivo (editor)
|
||||
* - mixer : mezclador VJ (camara + video + efectos, via Hydra)
|
||||
* - projectm : nativo (esta pagina se queda en negro y se superpone)
|
||||
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
|
||||
*/
|
||||
|
||||
const socket = io();
|
||||
const msgEl = document.getElementById("msg");
|
||||
const bcCanvas = document.getElementById("butterchurn");
|
||||
const hydraCanvas = document.getElementById("hydra");
|
||||
const shaderCanvas = document.getElementById("shaders");
|
||||
|
||||
let audioCtx, gainNode, analyser, micSource, micStream;
|
||||
let currentDevice = "";
|
||||
let state = null;
|
||||
let lastQrUrl = ""; // ultima URL para la que se genero el QR
|
||||
|
||||
// Estado del detector de ritmo, compartido por todos los motores
|
||||
const fosBeat = { bpm: 0, phase: 0, level: 0, isBeat: false };
|
||||
|
||||
// --- Butterchurn ---
|
||||
let bcViz = null, bcRAF = null, bcTimer = null;
|
||||
let bcPresets = {}, bcNames = [], bcIndex = 0, bcCurrent = "";
|
||||
let bcBeatCount = 0;
|
||||
|
||||
// --- Hydra / Mixer ---
|
||||
let hydra = null, hydraCode = "", mixerSig = "";
|
||||
let mixerVideo = "", mixerCam = null, mixerCamId = null;
|
||||
|
||||
// --- Shaders ---
|
||||
let shaderEngine = null, shaderCode = "";
|
||||
|
||||
/* Comunica un aviso al servidor para que aparezca en el panel de control.
|
||||
Asi el usuario siempre ve que ha pasado, no se queda el sistema mudo. */
|
||||
function report(level, message) {
|
||||
if (level === "error") console.error("[FOSFENO]", message);
|
||||
else console.warn("[FOSFENO]", message);
|
||||
try { socket.emit("stage_notify", { level: level, message: message }); }
|
||||
catch (e) { /* sin conexion */ }
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Detector de BPM (analisis de energia de graves en tiempo real)
|
||||
// ==========================================================================
|
||||
class BeatDetector {
|
||||
constructor(analyserNode) {
|
||||
this.analyser = analyserNode;
|
||||
this.freq = new Uint8Array(analyserNode.frequencyBinCount);
|
||||
this.history = [];
|
||||
this.HISTORY = 55;
|
||||
this.lastBeat = 0;
|
||||
this.intervals = [];
|
||||
this.bpm = 0;
|
||||
}
|
||||
|
||||
update(now) {
|
||||
this.analyser.getByteFrequencyData(this.freq);
|
||||
const n = this.freq.length;
|
||||
const hi = Math.max(2, Math.floor(n * 0.08));
|
||||
let e = 0;
|
||||
for (let i = 0; i < hi; i++) e += this.freq[i];
|
||||
e /= hi;
|
||||
|
||||
this.history.push(e);
|
||||
if (this.history.length > this.HISTORY) this.history.shift();
|
||||
const avg = this.history.reduce((s, v) => s + v, 0) / this.history.length;
|
||||
|
||||
let isBeat = false;
|
||||
const minGap = 60000 / 210; // tope de 210 BPM
|
||||
if (e > avg * 1.35 && e > 24 && now - this.lastBeat > minGap) {
|
||||
if (this.lastBeat > 0) {
|
||||
const dt = now - this.lastBeat;
|
||||
if (dt > 200 && dt < 2000) {
|
||||
this.intervals.push(dt);
|
||||
if (this.intervals.length > 40) this.intervals.shift();
|
||||
this._estimate();
|
||||
}
|
||||
}
|
||||
this.lastBeat = now;
|
||||
isBeat = true;
|
||||
}
|
||||
|
||||
fosBeat.level = e / 255;
|
||||
fosBeat.bpm = this.bpm;
|
||||
fosBeat.isBeat = isBeat;
|
||||
if (this.bpm > 0) {
|
||||
const beatMs = 60000 / this.bpm;
|
||||
fosBeat.phase = ((now - this.lastBeat) % beatMs) / beatMs;
|
||||
}
|
||||
return isBeat;
|
||||
}
|
||||
|
||||
_estimate() {
|
||||
if (this.intervals.length < 6) return;
|
||||
// Agrupa intervalos parecidos (tolerancia 8%) y elige el grupo mayoritario
|
||||
const groups = [];
|
||||
for (const iv of this.intervals) {
|
||||
const g = groups.find((x) => Math.abs(x.mean - iv) / x.mean < 0.08);
|
||||
if (g) { g.sum += iv; g.count++; g.mean = g.sum / g.count; }
|
||||
else groups.push({ sum: iv, count: 1, mean: iv });
|
||||
}
|
||||
groups.sort((a, b) => b.count - a.count);
|
||||
let bpm = 60000 / groups[0].mean;
|
||||
while (bpm < 70) bpm *= 2;
|
||||
while (bpm > 180) bpm /= 2;
|
||||
this.bpm = Math.round(bpm);
|
||||
}
|
||||
}
|
||||
let beatDetector = null;
|
||||
|
||||
// ==========================================================================
|
||||
// Motor de shaders GLSL (estilo Shadertoy, reactivo al audio y al BPM)
|
||||
// Uniforms: u_resolution, u_time, u_bass, u_mid, u_treble, u_level,
|
||||
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
|
||||
// ==========================================================================
|
||||
class ShaderEngine {
|
||||
constructor(canvas, analyserNode) {
|
||||
this.canvas = canvas;
|
||||
this.analyser = analyserNode;
|
||||
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
this.raf = null;
|
||||
this.program = null;
|
||||
this.loc = {};
|
||||
this.startTime = performance.now();
|
||||
this.fftData = new Uint8Array(analyserNode.frequencyBinCount);
|
||||
this._initQuad();
|
||||
this._initFFTTexture();
|
||||
}
|
||||
|
||||
_initQuad() {
|
||||
const gl = this.gl;
|
||||
this.quadBuf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
}
|
||||
|
||||
_initFFTTexture() {
|
||||
const gl = this.gl;
|
||||
this.fftTex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
_compile(type, src) {
|
||||
const gl = this.gl;
|
||||
const sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
report("error", "El shader no compila. Revisa el codigo GLSL "
|
||||
+ "(el detalle tecnico esta en la consola del navegador).");
|
||||
gl.deleteShader(sh);
|
||||
return null;
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
load(fragBody) {
|
||||
const gl = this.gl;
|
||||
const vs = "attribute vec2 p; void main(){ gl_Position = vec4(p,0.0,1.0); }";
|
||||
const header =
|
||||
"precision highp float;\n" +
|
||||
"uniform vec2 u_resolution;\n" +
|
||||
"uniform float u_time;\n" +
|
||||
"uniform float u_bass;\n" +
|
||||
"uniform float u_mid;\n" +
|
||||
"uniform float u_treble;\n" +
|
||||
"uniform float u_level;\n" +
|
||||
"uniform float u_bpm;\n" +
|
||||
"uniform float u_beat;\n" +
|
||||
"uniform sampler2D u_fft;\n";
|
||||
const vsh = this._compile(gl.VERTEX_SHADER, vs);
|
||||
const fsh = this._compile(gl.FRAGMENT_SHADER, header + fragBody);
|
||||
if (!vsh || !fsh) return false;
|
||||
const prog = gl.createProgram();
|
||||
gl.attachShader(prog, vsh);
|
||||
gl.attachShader(prog, fsh);
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
console.error("[FOSFENO] link:", gl.getProgramInfoLog(prog));
|
||||
return false;
|
||||
}
|
||||
if (this.program) gl.deleteProgram(this.program);
|
||||
this.program = prog;
|
||||
const u = (name) => gl.getUniformLocation(prog, name);
|
||||
this.loc = {
|
||||
p: gl.getAttribLocation(prog, "p"),
|
||||
res: u("u_resolution"), time: u("u_time"),
|
||||
bass: u("u_bass"), mid: u("u_mid"), treble: u("u_treble"),
|
||||
level: u("u_level"), bpm: u("u_bpm"), beat: u("u_beat"), fft: u("u_fft"),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
_bands() {
|
||||
this.analyser.getByteFrequencyData(this.fftData);
|
||||
const n = this.fftData.length;
|
||||
const avg = (lo, hi) => {
|
||||
let s = 0;
|
||||
for (let i = lo; i < hi; i++) s += this.fftData[i];
|
||||
return s / Math.max(1, (hi - lo) * 255);
|
||||
};
|
||||
return [
|
||||
avg(1, Math.floor(n * 0.06)),
|
||||
avg(Math.floor(n * 0.06), Math.floor(n * 0.30)),
|
||||
avg(Math.floor(n * 0.30), Math.floor(n * 0.75)),
|
||||
avg(1, n),
|
||||
];
|
||||
}
|
||||
|
||||
_frame() {
|
||||
const gl = this.gl;
|
||||
const w = this.canvas.width = window.innerWidth;
|
||||
const h = this.canvas.height = window.innerHeight;
|
||||
gl.viewport(0, 0, w, h);
|
||||
if (this.program) {
|
||||
const b = this._bands();
|
||||
gl.useProgram(this.program);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
|
||||
this.fftData.length, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, this.fftData);
|
||||
gl.uniform1i(this.loc.fft, 0);
|
||||
gl.uniform2f(this.loc.res, w, h);
|
||||
gl.uniform1f(this.loc.time, (performance.now() - this.startTime) / 1000);
|
||||
gl.uniform1f(this.loc.bass, b[0]);
|
||||
gl.uniform1f(this.loc.mid, b[1]);
|
||||
gl.uniform1f(this.loc.treble, b[2]);
|
||||
gl.uniform1f(this.loc.level, b[3]);
|
||||
gl.uniform1f(this.loc.bpm, fosBeat.bpm);
|
||||
gl.uniform1f(this.loc.beat, fosBeat.phase);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
|
||||
gl.enableVertexAttribArray(this.loc.p);
|
||||
gl.vertexAttribPointer(this.loc.p, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
this.raf = requestAnimationFrame(() => this._frame());
|
||||
}
|
||||
|
||||
start() { if (!this.raf) this._frame(); }
|
||||
stop() { if (this.raf) cancelAnimationFrame(this.raf); this.raf = null; }
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Audio: captura del microfono + seleccion de tarjeta
|
||||
// ==========================================================================
|
||||
async function acquireMic(deviceId) {
|
||||
if (micStream) micStream.getTracks().forEach((t) => t.stop());
|
||||
const constraints = {
|
||||
audio: {
|
||||
echoCancellation: false, noiseSuppression: false, autoGainControl: false,
|
||||
},
|
||||
};
|
||||
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
|
||||
micStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
if (micSource) micSource.disconnect();
|
||||
micSource = audioCtx.createMediaStreamSource(micStream);
|
||||
micSource.connect(gainNode);
|
||||
currentDevice = deviceId || "";
|
||||
}
|
||||
|
||||
async function initAudio() {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
await audioCtx.resume();
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 1.0;
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
gainNode.connect(analyser);
|
||||
await acquireMic(null);
|
||||
beatDetector = new BeatDetector(analyser);
|
||||
await enumerateInputs();
|
||||
}
|
||||
|
||||
/* Lista los microfonos y las camaras y los envia al panel. Se llama al
|
||||
arrancar y cada vez que el panel pide volver a buscar dispositivos. */
|
||||
async function enumerateInputs() {
|
||||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = devs.filter((d) => d.kind === "audioinput")
|
||||
.map((d, i) => ({ id: d.deviceId,
|
||||
name: d.label || ("Entrada de audio " + (i + 1)) }));
|
||||
const cameraInputs = devs.filter((d) => d.kind === "videoinput")
|
||||
.map((d, i) => ({ id: i, name: d.label || ("Camara " + (i + 1)) }));
|
||||
socket.emit("stage_meta", {
|
||||
audioDevices: audioInputs, cameraDevices: cameraInputs,
|
||||
});
|
||||
}
|
||||
|
||||
// Bucle permanente del detector de ritmo (independiente del motor activo)
|
||||
let lastBpmSent = 0;
|
||||
function beatLoop() {
|
||||
const now = performance.now();
|
||||
const isBeat = beatDetector ? beatDetector.update(now) : false;
|
||||
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
|
||||
|
||||
// Cambio de preset de Butterchurn sincronizado al compas
|
||||
if (isBeat && state && state.engine === "butterchurn" && state.power
|
||||
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
|
||||
bcBeatCount++;
|
||||
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
|
||||
bcBeatCount = 0;
|
||||
bcStep(1);
|
||||
}
|
||||
}
|
||||
if (now - lastBpmSent > 2000) {
|
||||
lastBpmSent = now;
|
||||
socket.emit("stage_status", { bpm: fosBeat.bpm });
|
||||
}
|
||||
requestAnimationFrame(beatLoop);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Butterchurn
|
||||
// ==========================================================================
|
||||
function initButterchurn() {
|
||||
const bch = window.butterchurn.default || window.butterchurn;
|
||||
bcViz = bch.createVisualizer(audioCtx, bcCanvas, {
|
||||
width: window.innerWidth, height: window.innerHeight,
|
||||
pixelRatio: window.devicePixelRatio || 1,
|
||||
});
|
||||
bcViz.connectAudio(gainNode);
|
||||
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
|
||||
bcPresets = bp.getPresets();
|
||||
bcNames = Object.keys(bcPresets);
|
||||
bcIndex = Math.floor(Math.random() * bcNames.length);
|
||||
socket.emit("stage_meta", { butterchurnPresets: bcNames });
|
||||
}
|
||||
|
||||
function loadButterchurnPreset(name) {
|
||||
if (!bcPresets[name]) return;
|
||||
bcCurrent = name;
|
||||
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
|
||||
socket.emit("stage_status", { label: name });
|
||||
}
|
||||
|
||||
function bcStep(delta) {
|
||||
bcIndex = (bcIndex + delta + bcNames.length) % bcNames.length;
|
||||
loadButterchurnPreset(bcNames[bcIndex]);
|
||||
}
|
||||
|
||||
function bcLoop() {
|
||||
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
|
||||
bcViz.render();
|
||||
bcRAF = requestAnimationFrame(bcLoop);
|
||||
}
|
||||
|
||||
function startButterchurn() { if (!bcRAF) bcLoop(); }
|
||||
|
||||
function stopButterchurn() {
|
||||
if (bcRAF) cancelAnimationFrame(bcRAF);
|
||||
bcRAF = null;
|
||||
clearInterval(bcTimer);
|
||||
bcTimer = null;
|
||||
}
|
||||
|
||||
function refreshShuffle() {
|
||||
clearInterval(bcTimer);
|
||||
bcTimer = null;
|
||||
bcBeatCount = 0;
|
||||
if (state && state.engine === "butterchurn" && state.power
|
||||
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
|
||||
bcTimer = setInterval(() => bcStep(1),
|
||||
(state.butterchurn.interval || 20) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Hydra (codigo en vivo) + Mixer (mezclador VJ)
|
||||
// ==========================================================================
|
||||
function initHydra() {
|
||||
hydra = new Hydra({
|
||||
canvas: hydraCanvas, detectAudio: true, makeGlobal: true,
|
||||
width: window.innerWidth, height: window.innerHeight,
|
||||
});
|
||||
a.setSmooth(0.85);
|
||||
a.setBins(5);
|
||||
}
|
||||
|
||||
function runHydraCode(code) {
|
||||
if (!code) return;
|
||||
hydraCode = code;
|
||||
try {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(code);
|
||||
} catch (err) {
|
||||
report("error", "El codigo de Hydra ha fallado: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* Construye una cadena de codigo Hydra a partir de los ajustes del mezclador. */
|
||||
function buildMixerCode(m) {
|
||||
let base;
|
||||
if (m.source === "video") base = "src(s1)";
|
||||
else if (m.source === "mix") base = `src(s0).${m.blendMode}(src(s1), ${m.mix})`;
|
||||
else base = "src(s0)";
|
||||
|
||||
let c = base;
|
||||
if (m.kaleid > 1) c += `.kaleid(${m.kaleid})`;
|
||||
if (m.rotate) c += `.rotate(${m.rotate})`;
|
||||
if (m.pixelate > 0) {
|
||||
const px = Math.round(220 - m.pixelate * 210);
|
||||
c += `.pixelate(${px}, ${px})`;
|
||||
}
|
||||
if (m.hue) c += `.hue(${m.hue})`;
|
||||
if (m.saturate !== 1) c += `.saturate(${m.saturate})`;
|
||||
if (m.contrast !== 1) c += `.contrast(${m.contrast})`;
|
||||
if (m.brightness) c += `.brightness(${m.brightness})`;
|
||||
if (m.colorama > 0) c += `.colorama(${m.colorama})`;
|
||||
if (m.posterize > 0) c += `.posterize(${m.posterize}, 0.5)`;
|
||||
if (m.invert) c += ".invert()";
|
||||
if (m.beatPulse) c += ".scale(() => 1.0 + a.fft[0]*0.3)";
|
||||
if (m.feedback > 0) c += `.blend(o0, ${m.feedback})`;
|
||||
c += ".out(o0)";
|
||||
return c;
|
||||
}
|
||||
|
||||
/* Inicializa las fuentes (camara s0, video s1) solo cuando cambian. */
|
||||
function ensureMixerSources(m) {
|
||||
const wantCam = m.camOn && (m.source === "cam" || m.source === "mix");
|
||||
if (wantCam && (mixerCam !== true || mixerCamId !== m.cameraId)) {
|
||||
try {
|
||||
s0.initCam(m.cameraId);
|
||||
mixerCam = true;
|
||||
mixerCamId = m.cameraId;
|
||||
} catch (e) {
|
||||
report("error", "No se pudo activar la camara. Comprueba que la "
|
||||
+ "webcam USB esta conectada, o elige otra camara en el panel.");
|
||||
}
|
||||
} else if (!wantCam) {
|
||||
mixerCam = false;
|
||||
}
|
||||
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
|
||||
if (wantVideo && m.video !== mixerVideo) {
|
||||
try { s1.initVideo("/data/videos/" + encodeURIComponent(m.video)); }
|
||||
catch (e) {
|
||||
report("error", "No se pudo cargar el video '" + m.video + "'.");
|
||||
}
|
||||
mixerVideo = m.video;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMixer(m) {
|
||||
ensureMixerSources(m);
|
||||
const sig = JSON.stringify(m);
|
||||
if (sig !== mixerSig) {
|
||||
mixerSig = sig;
|
||||
runHydraCode(buildMixerCode(m));
|
||||
socket.emit("stage_status", { label: "Mezclador VJ" });
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Conmutacion de motores
|
||||
// ==========================================================================
|
||||
function show(canvas) {
|
||||
bcCanvas.classList.toggle("visible", canvas === bcCanvas);
|
||||
hydraCanvas.classList.toggle("visible", canvas === hydraCanvas);
|
||||
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
|
||||
document.getElementById("info").style.display = "none";
|
||||
msgEl.style.display = canvas ? "none" : "block";
|
||||
}
|
||||
|
||||
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
|
||||
proyector. Se ve al arrancar (hasta que alguien abre el panel) y cuando las
|
||||
visuales estan apagadas, para que conectarse sea inmediato. */
|
||||
function renderInfoScreen() {
|
||||
show(null);
|
||||
const net = state.network || {};
|
||||
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
||||
const ipUrl = "http://" + (net.ip || "...") + port + "/";
|
||||
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/";
|
||||
document.getElementById("info-url").textContent = ipUrl;
|
||||
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
|
||||
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
|
||||
lastQrUrl = ipUrl;
|
||||
try {
|
||||
const qr = qrcode(0, "M");
|
||||
qr.addData(ipUrl);
|
||||
qr.make();
|
||||
document.getElementById("info-qr").src = qr.createDataURL(7, 8);
|
||||
} catch (e) { /* sin QR: queda la direccion en texto */ }
|
||||
}
|
||||
document.getElementById("info").style.display = "flex";
|
||||
}
|
||||
|
||||
function applyState(next) {
|
||||
const prevDevice = currentDevice;
|
||||
state = next;
|
||||
if (gainNode) gainNode.gain.value = state.sensitivity;
|
||||
|
||||
// Cambio de tarjeta de audio
|
||||
if (state.audio.device && state.audio.device !== prevDevice) {
|
||||
acquireMic(state.audio.device).catch(() =>
|
||||
report("error", "No se pudo cambiar a la tarjeta de audio elegida."));
|
||||
}
|
||||
|
||||
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
|
||||
// el proyector muestra como conectarse (direccion + codigo QR).
|
||||
const showConnect = !(state.network && state.network.panelSeen) || !state.power;
|
||||
if (showConnect || state.engine === "projectm") {
|
||||
stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
if (hydra) hydra.hush();
|
||||
mixerSig = "";
|
||||
if (showConnect) renderInfoScreen();
|
||||
else show(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.engine === "butterchurn") {
|
||||
if (hydra) hydra.hush();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
mixerSig = "";
|
||||
show(bcCanvas);
|
||||
startButterchurn();
|
||||
const wanted = state.butterchurn.preset;
|
||||
if (wanted && wanted !== bcCurrent) loadButterchurnPreset(wanted);
|
||||
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
|
||||
refreshShuffle();
|
||||
|
||||
} else if (state.engine === "hydra") {
|
||||
stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
mixerSig = "";
|
||||
show(hydraCanvas);
|
||||
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
|
||||
|
||||
} else if (state.engine === "shaders") {
|
||||
stopButterchurn();
|
||||
if (hydra) hydra.hush();
|
||||
mixerSig = "";
|
||||
show(shaderCanvas);
|
||||
if (shaderEngine && state.shaders.code !== shaderCode) {
|
||||
if (shaderEngine.load(state.shaders.code)) shaderCode = state.shaders.code;
|
||||
}
|
||||
if (shaderEngine) shaderEngine.start();
|
||||
|
||||
} else if (state.engine === "mixer") {
|
||||
stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
show(hydraCanvas);
|
||||
applyMixer(state.mixer);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// WebSocket
|
||||
// ==========================================================================
|
||||
socket.on("state", (next) => applyState(next));
|
||||
|
||||
socket.on("stage_command", (cmd) => {
|
||||
if (!state) return;
|
||||
if (state.engine === "butterchurn") {
|
||||
if (cmd.action === "next") bcStep(1);
|
||||
else if (cmd.action === "prev") bcStep(-1);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("stage_rescan", () => {
|
||||
enumerateInputs().catch((e) =>
|
||||
report("warn", "No se pudieron volver a leer los dispositivos."));
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (hydra) hydra.setResolution(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Arranque
|
||||
// ==========================================================================
|
||||
async function boot() {
|
||||
try {
|
||||
if (typeof Hydra === "undefined" || typeof butterchurn === "undefined") {
|
||||
report("error", "Faltan librerias de visuales. Ejecuta install.sh "
|
||||
+ "de nuevo en la Raspberry.");
|
||||
msgEl.textContent = "ERROR: faltan librerias";
|
||||
return;
|
||||
}
|
||||
await initAudio();
|
||||
initButterchurn();
|
||||
initHydra();
|
||||
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
|
||||
beatLoop();
|
||||
msgEl.textContent = "FOSFENO listo";
|
||||
} catch (err) {
|
||||
report("error", "Fallo al arrancar el escenario: " + err.message
|
||||
+ ". Si menciona el microfono, comprueba que esta conectado.");
|
||||
msgEl.textContent = "ERROR: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
Loading…
Add table
Add a link
Reference in a new issue