Port the Auto VJ director from the Mac app (verified live on the Pi)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bf24d78e87
commit
2f6999996d
5 files changed
+99
-1
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<main>
|
||||
<!-- EFFETTI -->
|
||||
<section class="panel" data-tab="fx">
|
||||
<button id="auto-vj" class="wide" title="L'Auto VJ ascolta il brano (energia, BPM, drop) e cambia effetto da solo a tempo di musica">🤖 Auto VJ</button>
|
||||
<div class="row">
|
||||
<select id="fx-fam"><option value="-1">Tutte le famiglie</option></select>
|
||||
<input id="fx-search" placeholder="Cerca effetto…" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+43
-1
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user