From 2f6999996dbbf65d5a98e86a9b98f2ddc0214478 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 17 Jul 2026 15:32:21 +0200 Subject: [PATCH] Port the Auto VJ director from the Mac app (verified live on the Pi) Co-Authored-By: Claude Fable 5 --- public/css/mobile.css | 1 + public/index.html | 1 + public/js/control.js | 12 +++++++++++ public/js/engine/audio.js | 42 +++++++++++++++++++++++++++++++++++++ public/js/output.js | 44 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/public/css/mobile.css b/public/css/mobile.css index 9ed967b..8d402f4 100644 --- a/public/css/mobile.css +++ b/public/css/mobile.css @@ -36,6 +36,7 @@ button:active { filter: brightness(1.3); } button.wide { width: 100%; margin-top: 10px; font-size: 15px; } #audio-src { width: 100%; } button.danger { border-color: rgba(255,120,120,.5); color: #ffb3b3; background: rgba(255,80,80,.10); } +#auto-vj.vj-on { background: #2e7d32; border-color: #4caf50; color: #fff; } button.danger.armed { background: #c0392b; color: #fff; border-color: #c0392b; animation: pulse 1s infinite; } @keyframes pulse { 50% { filter: brightness(1.35); } } input[type=range] { -webkit-appearance: none; appearance: none; background: transparent; padding: 0; height: 34px; border: none; } diff --git a/public/index.html b/public/index.html index 244f623..223c41e 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@
+
diff --git a/public/js/control.js b/public/js/control.js index dbfd82b..bf373e8 100644 --- a/public/js/control.js +++ b/public/js/control.js @@ -20,6 +20,17 @@ const famSel = $('#fx-fam'); EFFECTS.families.forEach((n, i) => { const o = document.createElement('option'); o.value = i; o.textContent = n; famSel.appendChild(o); }); let curEffect = EFFECTS.list[0]; function applyEffect(e) { curEffect = e; send({ type: 'effect', effect: e }); $('#now').textContent = e.name; renderFx(); } + +// Auto VJ: the output-side director picks presets in time with the music. +let autoVjOn = false; +$('#auto-vj').addEventListener('click', () => send({ type: 'autoVj', on: !autoVjOn })); +function setAutoVj(m) { + autoVjOn = !!m.on; + const b = $('#auto-vj'); + b.classList.toggle('vj-on', autoVjOn); + b.textContent = autoVjOn ? '🤖 Auto VJ ● attivo' : '🤖 Auto VJ'; + if (autoVjOn && m.name) $('#now').textContent = m.name + (m.bpm ? ' · ' + m.bpm + ' BPM' : ''); +} function renderFx() { const fam = parseInt(famSel.value, 10); const q = $('#fx-search').value.trim().toLowerCase(); @@ -619,6 +630,7 @@ djv.onReport((m) => { case 'meters': setMeter('m-bass', m.bass); setMeter('m-mid', m.mid); setMeter('m-treble', m.treble); break; + case 'autoVj': setAutoVj(m); break; case 'devices': inputDevices = m.list || []; renderAudioSrc(); break; case 'recState': recOn = m.recording; updateRec(); break; case 'recSaved': $('#rec-status').textContent = '✅ Salvato: ' + m.path; break; diff --git a/public/js/engine/audio.js b/public/js/engine/audio.js index b18317e..1fa30c1 100644 --- a/public/js/engine/audio.js +++ b/public/js/engine/audio.js @@ -51,6 +51,17 @@ class AudioEngine { this._bassAvg = 0; // running average for beat detection this._beatCooldown = 0; + // Musical features for the Auto VJ director. + this.energyFast = 0; // ~0.2s EMA of level + this.energySlow = 0; // ~4s EMA of level (the track's section energy) + this.bpm = 0; // median of recent beat intervals + this.beatCount = 0; // total beats seen (for phrase-synced switching) + this.mood = 'groove'; // 'quiet' | 'groove' | 'peak' (with hysteresis) + this.drop = 0; // 1 for one frame when a drop is detected + this._beatIvs = []; + this._lastBeatT = 0; + this._dropCool = 0; + // Log-spaced spectrum for VU/bar visualisers. this.NB = 32; this.spectrum = new Float32Array(this.NB); @@ -270,10 +281,41 @@ class AudioEngine { if (b > this._bassAvg * 1.35 && b > 0.12 && this._beatCooldown <= 0) { this.beat = 1.0; this._beatCooldown = 8; // ~min frames between beats + // BPM: median of the recent beat-to-beat intervals (0.24-2s plausible). + const now = performance.now(); + if (this._lastBeatT) { + const iv = (now - this._lastBeatT) / 1000; + if (iv > 0.24 && iv < 2.0) { + this._beatIvs = this._beatIvs.slice(-15); + this._beatIvs.push(iv); + const s = this._beatIvs.slice().sort((a, b2) => a - b2); + this.bpm = Math.round(60 / s[s.length >> 1]); + } + } + this._lastBeatT = now; + this.beatCount++; } else { this.beat *= 0.86; // decay } + // Musical mood for the Auto VJ: slow energy places the section, a sudden + // fast/slow divergence right after a lull reads as a drop. + this.energyFast = this.energyFast * 0.92 + this.level * 0.08; + this.energySlow = this.energySlow * 0.995 + this.level * 0.005; + this.drop = 0; + this._dropCool--; + if (this.energyFast > this.energySlow * 1.6 && this.energyFast > 0.35 && + this.energySlow > 0.04 && this._dropCool <= 0) { + this.drop = 1; + this._dropCool = 360; // at most one drop flag every ~6s + } + let mood = this.mood; + if (this.energySlow < 0.12) mood = 'quiet'; + else if (this.energySlow > 0.38 || this.drop) mood = 'peak'; + else if (mood === 'quiet' && this.energySlow > 0.16) mood = 'groove'; + else if (mood === 'peak' && this.energySlow < 0.32) mood = 'groove'; + this.mood = mood; + // Log-spaced spectrum bands (for VU-meter style effects). const NB = this.NB; const minBin = 1; diff --git a/public/js/output.js b/public/js/output.js index 0c19de4..26e424a 100644 --- a/public/js/output.js +++ b/public/js/output.js @@ -314,7 +314,16 @@ async function reportDevices() { // ---------------------------------------------------------------- command bus djv.onControl(async (m) => { switch (m.type) { - case 'effect': viz.setEffect(m.effect); break; + case 'effect': + // A manual pick takes over from the Auto VJ. + if (autoVj) { autoVj = false; djv.report({ type: 'autoVj', on: false }); } + viz.setEffect(m.effect); + break; + case 'autoVj': + autoVj = !!m.on; + if (autoVj) { avLastSwitch = 0; avBeatMark = audio.beatCount || 0; } + djv.report({ type: 'autoVj', on: autoVj }); + break; case 'svg': loadCustomTexture(m.dataUrl); break; case 'recStart': startRecording(); break; case 'recStop': stopRecording(m); break; @@ -477,10 +486,43 @@ djv.onControl(async (m) => { const startTime = performance.now(); let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0; +// ---- Auto VJ: a director that adapts the visuals to the playing track ------ +// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset +// every 16 beats (or ~14s without beats), immediately on a detected drop, and +// draws from pools of families that fit the current mood. +let autoVj = false, avLastSwitch = 0, avBeatMark = 0, avRecent = []; +const AV_POOLS = { + quiet: ['Cielo', 'Aurora', 'Mare', 'Montagne', 'Galassia', 'Nuvole', 'Fluido', 'Onde'], + groove: ['Plasma', 'Vortice', 'Truchet', 'Esagoni', 'Spirale', 'Cellule', 'Gyroide', + 'Fluido', 'Fluido Onda', 'Fluido Vortici', 'Griglia Neon', 'Solidi 3D'], + peak: ['Julia', 'Iperspazio', 'Tunnel', 'Moiré', 'Fluido Anello', 'Fluido Fuoco', + 'Vortice', 'Griglia Neon', 'Cristalli'] +}; +function avPick(mood) { + const fams = AV_POOLS[mood] || AV_POOLS.groove; + const cands = window.EFFECTS.list.filter(e => fams.indexOf(e.familyName) >= 0 && + (mood === 'peak' ? e.speed >= 1.0 : e.speed <= 1.4) && avRecent.indexOf(e.name) < 0); + const e = cands.length ? cands[Math.floor(Math.random() * cands.length)] : window.EFFECTS.list[0]; + avRecent = [e.name].concat(avRecent).slice(0, 6); // don't repeat the last few + return e; +} +function avTick(now) { + if (!autoVj) return; + const dropNow = audio.drop === 1; + const due = (audio.beatCount - avBeatMark) >= 16 || (now - avLastSwitch) > 14000; + if (!due && !dropNow) return; + avBeatMark = audio.beatCount; avLastSwitch = now; + const mood = dropNow ? 'peak' : audio.mood; + const e = avPick(mood); + viz.setEffect(e); + djv.report({ type: 'autoVj', on: true, name: e.name, mood, bpm: audio.bpm || 0 }); +} + function frame() { const t = (performance.now() - startTime) / 1000; audio.update(); const a = audio.values; + avTick(performance.now()); viz.render(t * speed, a); // Report beat rising-edges so the control window can auto-cycle effects.