/* * 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. */ // Clave compartida (config.json -> auth.token). Viene en la propia direccion // del escenario; si no hay clave configurada, queda vacia y todo va como // siempre. Se reenvia en el QR para que el movil entre de un escaneo. const CLAVE = new URLSearchParams(location.search).get("k") || ""; const socket = io({ auth: { token: CLAVE } }); 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 currentSource = "mic"; // "mic" (micro de la sala) o "monitor" (lo que suena en el equipo) 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 { /* size: {width, height} fija el lienzo (capas del mapping, que se renderizan pequenas y las estira el mapper). Sin size, el shader ocupa la pantalla entera, que es como va el motor de shaders normal. */ constructor(canvas, analyserNode, size) { this.canvas = canvas; this.size = size || null; 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 = this.size ? this.size.width : window.innerWidth; const h = this.canvas.height = this.size ? this.size.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 || ""; } /* Busca la fuente "monitor" del sistema: en Linux, PulseAudio/PipeWire expone por cada salida de audio una entrada "Monitor of ..." que contiene exactamente lo que se esta reproduciendo (navegador, Spotify...), aunque suene por los cascos. Capturarla permite visuales reactivas sin micro. Si el usuario ya eligio un monitor concreto en la lista, se respeta. */ async function resolveMonitorId() { const devs = await navigator.mediaDevices.enumerateDevices(); const inputs = devs.filter((d) => d.kind === "audioinput"); const isMonitor = (d) => /monitor|loopback|what.?u.?hear|stereo mix/i.test(d.label || ""); const chosen = inputs.find((d) => d.deviceId === (state && state.audio && state.audio.device) && isMonitor(d)); if (chosen) return chosen.deviceId; const mon = inputs.find(isMonitor); return mon ? mon.deviceId : null; } /* Conecta la captura segun la fuente elegida en el panel. Las llamadas se encadenan una detras de otra: dos cambios rapidos en el panel ya no pueden solaparse y dejar streams huerfanos capturando. */ let audioChain = Promise.resolve(); function applyAudioSource() { const run = audioChain.catch(() => {}).then(() => doApplyAudioSource()); audioChain = run.catch(() => {}); return run; } async function doApplyAudioSource() { const src = (state && state.audio && state.audio.source) || "mic"; if (src === "monitor") { const id = await resolveMonitorId(); if (id) { await acquireMic(id); } else { /* Chromium oculta los monitores de PulseAudio/PipeWire al enumerar dispositivos (Firefox si los muestra). Plan B: capturamos la entrada por defecto y el servidor mueve este stream al monitor de la salida con pactl. Solo funciona con escenario y servidor en la misma maquina, que es el caso del kiosko y del modo portatil. */ await acquireMic(null); socket.emit("audio_route", { target: "monitor" }); } } else { const dev = (state && state.audio && state.audio.device) || null; await acquireMic(dev); /* Sin tarjeta concreta elegida, PipeWire puede "recordar" que el navegador capturaba el monitor (stream-restore) y dejarlo alli: pedimos al servidor que devuelva la captura a la entrada por defecto. */ if (!dev) socket.emit("audio_route", { target: "mic" }); } currentSource = src; } 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); beatDetector = new BeatDetector(analyser); // Sin entrada de audio las visuales siguen (aunque no reactivas): mejor // avisar que dejar el proyector en negro. try { await acquireMic(null); } catch (err) { report("warn", "Sin entrada de audio (" + err.message + "). Las " + "visuales funcionan pero no reaccionan a la musica: conecta un " + "microfono y pulsa 'Buscar dispositivos' y 'Aplicar entrada' " + "en el panel."); } await enumerateInputs().catch(() => {}); } /* 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 (tanto si es el // motor elegido como si hace de fondo del mezclador) if (isBeat && butterLive() && 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); if (!window.butterchurnPresets) { report("error", "Faltan los presets de Butterchurn (libreria no " + "copiada). Vuelve a ejecutar install.sh."); return; } 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] || !bcViz) return; bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle // El indice sigue al preset que suena: asi 'siguiente' continua desde el // que se ve, tanto si se eligio en la lista como si vino del cambio // automatico. const i = bcNames.indexOf(name); if (i >= 0) bcIndex = i; // El estado guarda siempre el preset que esta en pantalla. Sin esto, el // cambio automatico volvia atras en cuanto llegaba cualquier estado // nuevo (el escenario recargaba el ultimo preset elegido a mano). if (!state || state.butterchurn.preset !== name) { socket.emit("update_settings", { engine: "butterchurn", patch: { preset: name } }); } try { bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7); socket.emit("stage_status", { label: (state && state.engine === "mixer") ? "Mezclador VJ · fondo: " + name : name, }); } catch (e) { report("warn", "El preset '" + name + "' fallo al cargar en esta " + "tarjeta grafica. Pasa a otro con 'siguiente' o eligelo en la lista."); } } function bcStep(delta) { bcIndex = (bcIndex + delta + bcNames.length) % bcNames.length; loadButterchurnPreset(bcNames[bcIndex]); } /* Butterchurn esta pintando ahora mismo? Puede ser el motor elegido o el fondo del mezclador (la capa de abajo). En los dos casos manda el mismo preset y el mismo cambio automatico, para que el fondo del mezclador se maneje igual que el motor Butter. */ function butterLive() { if (!state || !state.power) return false; if (state.engine === "butterchurn") return true; return state.engine === "mixer" && state.mixer.fondo === "butter" && (state.mixer.source === "cam" || state.mixer.source === "mix"); } function bcLoop() { bcViz.setRendererSize(window.innerWidth, window.innerHeight); bcViz.render(); bcRAF = requestAnimationFrame(bcLoop); } function startButterchurn() { if (!bcRAF) bcLoop(); } /* Ajustes con los que se monto el temporizador del cambio automatico. Sin esta firma, cada estado nuevo (mover un deslizador, subir un clip...) reiniciaba la cuenta y con intervalos largos el preset no cambiaba nunca. */ let bcShuffleSig = ""; function stopButterchurn() { if (bcRAF) cancelAnimationFrame(bcRAF); bcRAF = null; clearInterval(bcTimer); bcTimer = null; bcShuffleSig = ""; } function refreshShuffle() { const b = (state && state.butterchurn) || {}; const sig = butterLive() ? [b.shuffle, b.intervalMode, b.interval].join("|") : "off"; if (sig === bcShuffleSig) return; bcShuffleSig = sig; clearInterval(bcTimer); bcTimer = null; bcBeatCount = 0; if (butterLive() && b.shuffle && b.intervalMode === "seconds") { bcTimer = setInterval(() => bcStep(1), (b.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" && m.blendMode === "recorte") { // Recorte: el negro del clip se hace transparente (luma key) y la // figura queda DELANTE del fondo. El deslizador de mezcla ajusta el // umbral del recorte (mas alto = se come mas borde oscuro). const umbral = (0.05 + 0.30 * Number(m.mix || 0.5)).toFixed(3); base = `src(s0).layer(src(s1).luma(${umbral}, 0.15))`; } 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; } /* Fuentes del mezclador (camara s0, video s1). No usamos s0.initCam ni s1.initVideo de Hydra porque se tragan los errores: si la camara falla o el video no carga, la pantalla se queda en negro sin decir nada. Creamos nosotros la captura y el