/* * 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();