Mapping in-browser (Fase 1) + trabajo en curso
Projection mapping editable desde el navegador, sin herramientas externas: - web/stage/mapper.js (nuevo): compositor WebGL que texturiza el motor activo sobre superficies deformables (homografia por vertice: keystone correcto). - Editor con raton en el escenario (crear, arrastrar esquinas, mover, borrar) y previsualizacion arrastrable en el panel (raton/tactil) para editar desde el movil sin tocar la ventana de visuales. - Backend: estado "mapping" + evento set_mapping, persistido en data/mapping.json (ignorado en git). Seccion "Mapping" en el panel + texto de ayuda. - Solo motores web (butterchurn/hydra/shaders/mixer); projectM no se mapea. - .gitignore: excluye config de runtime (mapping.json, pm-favoritos.json). Incluye ademas trabajo en curso previo sin commitear (server.py, stage.js, panel.js/css, ayuda.json, hydra-sketches.json, problemas.md, start-projectm.sh), subido junto a peticion del autor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b1546cecc
commit
8dd58bb293
12 changed files with 1815 additions and 85 deletions
|
|
@ -17,6 +17,7 @@ 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
|
||||
|
||||
|
|
@ -268,6 +269,59 @@ async function acquireMic(deviceId) {
|
|||
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();
|
||||
|
|
@ -277,9 +331,18 @@ async function initAudio() {
|
|||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
gainNode.connect(analyser);
|
||||
await acquireMic(null);
|
||||
beatDetector = new BeatDetector(analyser);
|
||||
await enumerateInputs();
|
||||
// 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
|
||||
|
|
@ -329,6 +392,11 @@ function initButterchurn() {
|
|||
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);
|
||||
|
|
@ -337,10 +405,15 @@ function initButterchurn() {
|
|||
}
|
||||
|
||||
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 });
|
||||
if (!bcPresets[name] || !bcViz) return;
|
||||
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
|
||||
try {
|
||||
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
|
||||
socket.emit("stage_status", { label: 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) {
|
||||
|
|
@ -401,6 +474,13 @@ function runHydraCode(code) {
|
|||
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)";
|
||||
|
||||
|
|
@ -424,33 +504,174 @@ function buildMixerCode(m) {
|
|||
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)) {
|
||||
/* 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 <video>, avisamos al panel de cualquier fallo y
|
||||
solo cuando estan listos se los pasamos a Hydra con init({src}). */
|
||||
let mixerCamStream = null; // stream de la webcam, para poder apagarla
|
||||
let mixerCamPending = false; // evita dos aperturas de camara a la vez
|
||||
let mixerCamFailed = null; // cameraId que fallo (no reintentar en bucle)
|
||||
let mixerVideoEl = null; // elemento <video> del clip actual
|
||||
let mixerWarned = ""; // ultimo aviso de fuente no visible mostrado
|
||||
let mixerButter = false; // s0 es el lienzo de Butterchurn (fondo butter)
|
||||
|
||||
function stopMixerCam() {
|
||||
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
|
||||
mixerCamStream = null;
|
||||
mixerCam = false;
|
||||
mixerCamId = null;
|
||||
}
|
||||
|
||||
/* Apaga las fuentes del mezclador al salir de el (camara con su LED
|
||||
incluido) y fuerza a recomponer la cadena al volver. */
|
||||
function stopMixer() {
|
||||
mixerSig = "";
|
||||
mixerWarned = "";
|
||||
mixerCamFailed = null;
|
||||
mixerButter = false;
|
||||
stopMixerCam();
|
||||
if (mixerVideoEl) mixerVideoEl.pause();
|
||||
}
|
||||
|
||||
async function initCamSource(index) {
|
||||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||
const cams = devs.filter((d) => d.kind === "videoinput");
|
||||
if (!cams.length) throw new Error("no hay ninguna camara conectada");
|
||||
const pick = cams[index] || cams[0];
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false, video: { deviceId: { exact: pick.deviceId } },
|
||||
});
|
||||
const vid = document.createElement("video");
|
||||
vid.muted = true;
|
||||
vid.playsInline = true;
|
||||
vid.srcObject = stream;
|
||||
await vid.play();
|
||||
s0.init({ src: vid, dynamic: true });
|
||||
return stream;
|
||||
}
|
||||
|
||||
function initVideoSource(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const vid = document.createElement("video");
|
||||
vid.muted = true;
|
||||
vid.loop = true;
|
||||
vid.playsInline = true;
|
||||
vid.addEventListener("loadeddata", () => {
|
||||
vid.play().catch(() => { /* el flag de autoplay del kiosko lo permite */ });
|
||||
s1.init({ src: vid, dynamic: true });
|
||||
resolve(vid);
|
||||
});
|
||||
vid.addEventListener("error", () =>
|
||||
reject(new Error("formato no soportado o archivo danado")));
|
||||
vid.src = "/data/videos/" + encodeURIComponent(name);
|
||||
});
|
||||
}
|
||||
|
||||
// La galeria tambien admite imagenes fijas (jpg, png, gif, webp)
|
||||
const IMAGE_RE = /\.(jpe?g|png|gif|webp)$/i;
|
||||
|
||||
function initImageSource(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
s1.init({ src: img, dynamic: false });
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () =>
|
||||
reject(new Error("no se pudo cargar la imagen"));
|
||||
img.src = "/data/videos/" + encodeURIComponent(name);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureMixerSources(m) {
|
||||
// Fondo "butter": el lienzo de Butterchurn sigue renderizando (oculto,
|
||||
// el visible es el de Hydra) y hace de capa de abajo s0 en lugar de la
|
||||
// camara. Asi los recortes de personajes van DELANTE de MilkDrop.
|
||||
const wantButter = m.fondo === "butter"
|
||||
&& (m.source === "cam" || m.source === "mix");
|
||||
if (wantButter && bcViz) {
|
||||
stopMixerCam();
|
||||
if (!bcCurrent) {
|
||||
loadButterchurnPreset((state && state.butterchurn.preset)
|
||||
|| bcNames[bcIndex]);
|
||||
}
|
||||
startButterchurn();
|
||||
if (!mixerButter) {
|
||||
mixerButter = true;
|
||||
s0.init({ src: bcCanvas, dynamic: true });
|
||||
}
|
||||
} else if (mixerButter) {
|
||||
mixerButter = false;
|
||||
stopButterchurn();
|
||||
}
|
||||
|
||||
const wantCam = !wantButter && m.camOn
|
||||
&& (m.source === "cam" || m.source === "mix");
|
||||
if (wantCam && !mixerCamPending && mixerCamFailed !== m.cameraId
|
||||
&& (mixerCam !== true || mixerCamId !== m.cameraId)) {
|
||||
mixerCamPending = true;
|
||||
try {
|
||||
s0.initCam(m.cameraId);
|
||||
stopMixerCam();
|
||||
mixerCamStream = await initCamSource(m.cameraId);
|
||||
mixerCam = true;
|
||||
mixerCamId = m.cameraId;
|
||||
mixerCamFailed = null;
|
||||
} catch (e) {
|
||||
report("error", "No se pudo activar la camara. Comprueba que la "
|
||||
+ "webcam USB esta conectada, o elige otra camara en el panel.");
|
||||
stopMixerCam();
|
||||
mixerCamFailed = m.cameraId;
|
||||
report("error", "No se pudo activar la camara (" + e.message + "). "
|
||||
+ "Comprueba que esta conectada, pulsa 'Buscar dispositivos' y "
|
||||
+ "apaga y enciende la casilla 'Camara' para reintentar.");
|
||||
} finally {
|
||||
mixerCamPending = false;
|
||||
}
|
||||
} else if (!wantCam) {
|
||||
mixerCam = false;
|
||||
} else if (!wantCam && mixerCam) {
|
||||
stopMixerCam();
|
||||
mixerCamFailed = null;
|
||||
}
|
||||
|
||||
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 + "'.");
|
||||
const name = m.video;
|
||||
mixerVideo = name; // evita recargar el mismo clip con cada estado
|
||||
if (mixerVideoEl) { mixerVideoEl.pause(); mixerVideoEl.src = ""; mixerVideoEl = null; }
|
||||
try {
|
||||
if (IMAGE_RE.test(name)) {
|
||||
await initImageSource(name); // imagen fija: sin elemento video
|
||||
} else {
|
||||
mixerVideoEl = await initVideoSource(name);
|
||||
}
|
||||
} catch (e) {
|
||||
mixerVideo = ""; // deja reintentar eligiendolo otra vez en la lista
|
||||
report("error", "No se pudo cargar '" + name + "': "
|
||||
+ e.message + ". Prueba con un .mp4 (H.264), .webm, .jpg o .png.");
|
||||
}
|
||||
mixerVideo = m.video;
|
||||
} else if (wantVideo && mixerVideoEl && mixerVideoEl.paused) {
|
||||
// Al volver al mezclador el clip estaba pausado por stopMixer()
|
||||
mixerVideoEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/* Texto de aviso si la fuente elegida no puede verse todavia. */
|
||||
function mixerWarnings(m) {
|
||||
const w = [];
|
||||
if ((m.source === "cam" || m.source === "mix") && !m.camOn
|
||||
&& m.fondo !== "butter")
|
||||
w.push("La camara esta apagada: activa la casilla 'Camara'.");
|
||||
if ((m.source === "video" || m.source === "mix") && !m.video)
|
||||
w.push(((state && state.meta.videos) || []).length
|
||||
? "No hay video elegido: selecciona uno en la lista."
|
||||
: "No hay videos: copia archivos a data/videos y pulsa el boton de recargar.");
|
||||
return w.join(" ");
|
||||
}
|
||||
|
||||
function applyMixer(m) {
|
||||
ensureMixerSources(m);
|
||||
ensureMixerSources(m).catch((e) =>
|
||||
report("error", "El mezclador fallo preparando las fuentes: " + e.message));
|
||||
const warn = mixerWarnings(m);
|
||||
if (warn && warn !== mixerWarned) report("warn", warn);
|
||||
mixerWarned = warn;
|
||||
const sig = JSON.stringify(m);
|
||||
if (sig !== mixerSig) {
|
||||
mixerSig = sig;
|
||||
|
|
@ -468,6 +689,8 @@ function show(canvas) {
|
|||
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
|
||||
document.getElementById("info").style.display = "none";
|
||||
msgEl.style.display = canvas ? "none" : "block";
|
||||
// el mapper (si el mapping está activo) usa este canvas como textura
|
||||
if (window.FosMapper) FosMapper.setSource(canvas);
|
||||
}
|
||||
|
||||
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
|
||||
|
|
@ -495,13 +718,19 @@ function renderInfoScreen() {
|
|||
|
||||
function applyState(next) {
|
||||
const prevDevice = currentDevice;
|
||||
const prevSource = currentSource;
|
||||
state = next;
|
||||
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
|
||||
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."));
|
||||
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
|
||||
const source = state.audio.source || "mic";
|
||||
const sourceChanged = source !== prevSource;
|
||||
const deviceChanged = source === "mic"
|
||||
&& state.audio.device && state.audio.device !== prevDevice;
|
||||
if (audioCtx && (sourceChanged || deviceChanged)) {
|
||||
applyAudioSource().catch(() =>
|
||||
report("error", "No se pudo cambiar la fuente de audio elegida."));
|
||||
}
|
||||
|
||||
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
|
||||
|
|
@ -511,16 +740,23 @@ function applyState(next) {
|
|||
stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
if (hydra) hydra.hush();
|
||||
mixerSig = "";
|
||||
if (showConnect) renderInfoScreen();
|
||||
else show(null);
|
||||
stopMixer();
|
||||
if (showConnect) {
|
||||
renderInfoScreen();
|
||||
} else {
|
||||
// Motor projectM: esta pagina queda en negro debajo de su ventana.
|
||||
msgEl.textContent = (state.projectm && state.projectm.available)
|
||||
? "projectM nativo en marcha (en su propia ventana)"
|
||||
: "projectM no esta instalado: elige otro motor en el panel";
|
||||
show(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.engine === "butterchurn") {
|
||||
if (hydra) hydra.hush();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
mixerSig = "";
|
||||
stopMixer();
|
||||
if (!bcViz) { show(null); return; }
|
||||
show(bcCanvas);
|
||||
startButterchurn();
|
||||
|
|
@ -532,7 +768,7 @@ function applyState(next) {
|
|||
} else if (state.engine === "hydra") {
|
||||
stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
mixerSig = "";
|
||||
stopMixer();
|
||||
if (!hydra) { show(null); return; }
|
||||
show(hydraCanvas);
|
||||
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
|
||||
|
|
@ -540,16 +776,21 @@ function applyState(next) {
|
|||
} else if (state.engine === "shaders") {
|
||||
stopButterchurn();
|
||||
if (hydra) hydra.hush();
|
||||
mixerSig = "";
|
||||
stopMixer();
|
||||
if (!shaderEngine) { show(null); return; }
|
||||
show(shaderCanvas);
|
||||
if (shaderEngine && state.shaders.code !== shaderCode) {
|
||||
if (shaderEngine.load(state.shaders.code)) shaderCode = state.shaders.code;
|
||||
// Recuerda el intento aunque no compile: asi el aviso de error sale
|
||||
// una sola vez y no se repite con cada actualizacion del estado.
|
||||
shaderCode = state.shaders.code;
|
||||
shaderEngine.load(state.shaders.code);
|
||||
}
|
||||
if (shaderEngine) shaderEngine.start();
|
||||
|
||||
} else if (state.engine === "mixer") {
|
||||
stopButterchurn();
|
||||
// Con fondo "butter" el lienzo de Butterchurn debe seguir renderizando
|
||||
// (oculto): es la capa de abajo del mezclador.
|
||||
if (state.mixer.fondo !== "butter") stopButterchurn();
|
||||
if (shaderEngine) shaderEngine.stop();
|
||||
if (!hydra) { show(null); return; }
|
||||
show(hydraCanvas);
|
||||
|
|
@ -562,6 +803,14 @@ function applyState(next) {
|
|||
// ==========================================================================
|
||||
socket.on("state", (next) => applyState(next));
|
||||
|
||||
// --- Mapper (projection mapping en el navegador) ---
|
||||
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
|
||||
if (window.FosMapper) {
|
||||
FosMapper.init({
|
||||
onChange: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
|
||||
});
|
||||
}
|
||||
|
||||
socket.on("stage_command", (cmd) => {
|
||||
if (!state) return;
|
||||
if (state.engine === "butterchurn") {
|
||||
|
|
@ -575,12 +824,13 @@ socket.on("stage_rescan", () => {
|
|||
report("warn", "No se pudieron volver a leer los dispositivos."));
|
||||
});
|
||||
|
||||
// El panel pide reconectar la captura del microfono (boton "Aplicar").
|
||||
// El panel pide reconectar la captura de audio (boton "Aplicar entrada").
|
||||
socket.on("stage_reacquire", () => {
|
||||
const dev = (state && state.audio && state.audio.device) || null;
|
||||
acquireMic(dev)
|
||||
.then(() => report("info", "Microfono conectado."))
|
||||
.catch(() => report("error", "No se pudo conectar con el microfono elegido."));
|
||||
applyAudioSource()
|
||||
.then(() => report("info", (state && state.audio && state.audio.source) === "monitor"
|
||||
? "Capturando el audio del navegador/sistema."
|
||||
: "Microfono conectado."))
|
||||
.catch(() => report("error", "No se pudo conectar con la entrada de audio elegida."));
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
|
|
@ -591,15 +841,14 @@ window.addEventListener("resize", () => {
|
|||
// Arranque
|
||||
// ==========================================================================
|
||||
async function boot() {
|
||||
// El audio es la base de todo: si falla, no se puede continuar.
|
||||
// El audio va primero, pero si falla las visuales siguen adelante:
|
||||
// un escenario sin reactividad es mejor que un proyector en negro.
|
||||
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;
|
||||
+ ". Las visuales funcionaran sin reaccionar a la musica.");
|
||||
}
|
||||
// Cada motor se inicia por separado: si una libreria falta o falla, los
|
||||
// demas motores siguen funcionando igual.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue