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
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