fosfeno/web/stage/stage.js
hacklab c8b51615ba Corregir rutas de Butterchurn en node_modules y aislar el arranque de cada motor
Butterchurn publica sus builds en lib/ (butterchurn.min.js y butterchurnPresets.min.js), no en dist/base.min.js. El instalador ahora busca los ficheros por nombre con find_copy, robusto a cambios de carpeta entre versiones. Ademas el escenario inicia cada motor por separado: si una libreria falta, los demas siguen funcionando (antes un fallo de Butterchurn impedia que Hydra se inicializara, de ahi el error 'src is not defined').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:18:39 +02:00

612 lines
21 KiB
JavaScript

/*
* 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 = "";
if (!bcViz) { show(null); return; }
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 = "";
if (!hydra) { show(null); return; }
show(hydraCanvas);
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
} else if (state.engine === "shaders") {
stopButterchurn();
if (hydra) hydra.hush();
mixerSig = "";
if (!shaderEngine) { show(null); return; }
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();
if (!hydra) { show(null); return; }
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() {
// El audio es la base de todo: si falla, no se puede continuar.
try {
await initAudio();
beatLoop();
} catch (err) {
report("error", "No se pudo iniciar el audio: " + err.message
+ ". Comprueba el microfono y recarga la pagina.");
msgEl.textContent = "ERROR: audio (" + err.message + ")";
return;
}
// Cada motor se inicia por separado: si una libreria falta o falla, los
// demas motores siguen funcionando igual.
try {
if (typeof butterchurn !== "undefined") initButterchurn();
else report("warn", "Butterchurn no esta disponible (libreria no copiada).");
} catch (e) { report("error", "Butterchurn fallo al iniciar: " + e.message); }
try {
if (typeof Hydra !== "undefined") initHydra();
else report("error", "Hydra no esta disponible (libreria no copiada).");
} catch (e) { report("error", "Hydra fallo al iniciar: " + e.message); }
try {
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
} catch (e) { report("error", "El motor de shaders fallo: " + e.message); }
msgEl.textContent = "FOSFENO listo";
}
boot();