Effects engine, SVG/VU/silhouette categories, pad bank, MP4 recording, dual-window UI

- Parametric effect engine: 23 shader families × palettes × variants (~2300 presets)
  with a filterable library and an effect sequence (auto-cycle by time or beat).
- Effect categories: fractals/abstract, dancing silhouettes, SVG/Image source
  (loads any SVG/image as a recolourable audio-reactive effect; 50+ bundled CC0 SVGs),
  and VU-Meter (32-band spectrum bars, analogue needle, stereo LEDs).
- Dual-window architecture: control panel + fullscreen output on an external display
  (reliable via simple-fullscreen); tabbed control UI (Audio, Effetti, Pad, Immagini,
  Testo, Registra, Schermo).
- Audio: file playback + live input + output device selection, per-band visual EQ,
  audio playlist (reorder, hotkeys, per-track play/pause, repeat).
- Pad bank: 5×4 launchpad with persistent assignments; pressing a pad stops the current.
- Overlays: slideshow images (resizable), two freely positioned/resizable logos,
  scrolling ticker text.
- MP4 recording of the composited output (visual + overlays + audio) via MediaRecorder
  + ffmpeg, with selectable aspect ratios (16:9, 9:16, 1:1, 4:3, 3:4, 21:9).
- App icon (.icns) and dock icon; author Dj LuZa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-19 15:25:39 +02:00
1 parent b32ea47ae5
commit 47728d0d2a
76 files changed
+21794 -459

No files matched your search

+66 -10
View File
@@ -16,17 +16,37 @@ class AudioEngine {
this.outGain.gain.value = 1.0;
this.outGain.connect(this.ctx.destination);
// Tap for recording: every source also feeds this destination so the
// recorder captures file playback and live input alike.
this.recordDest = this.ctx.createMediaStreamDestination();
// Silent keep-alive so the audio track always produces frames (otherwise,
// with no source connected, the recorder stalls with empty data).
try {
const keep = this.ctx.createConstantSource();
keep.offset.value = 0;
keep.connect(this.recordDest);
keep.start();
} catch (e) { /* ConstantSource unsupported: live/file audio still flows */ }
this.sourceNode = null; // current MediaElement / MediaStream source
this.mediaEl = null; // <audio> element when playing a file
this.stream = null; // MediaStream when using live input
this.mode = 'none';
this.onEnded = null; // callback fired when a (non-looping) track ends
// Smoothed band values and beat state.
this.gain = 1.0; // user reactivity multiplier
this.gain = 1.0; // master reactivity multiplier
this.bassGain = 1.0; // per-band visual intensity
this.midGain = 1.0;
this.trebleGain = 1.0;
this.bass = 0; this.mid = 0; this.treble = 0; this.level = 0;
this.beat = 0;
this._bassAvg = 0; // running average for beat detection
this._beatCooldown = 0;
// Log-spaced spectrum for VU/bar visualisers.
this.NB = 32;
this.spectrum = new Float32Array(this.NB);
}
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
@@ -38,20 +58,22 @@ class AudioEngine {
}
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
// the speakers.
async loadFile(fileOrSrc) {
// the speakers. opts.loop controls looping (default true for a single file).
async loadFile(fileOrSrc, opts = {}) {
this.resume();
this._disconnectSource();
const el = new Audio();
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
el.loop = true;
el.loop = opts.loop !== undefined ? opts.loop : true;
el.crossOrigin = 'anonymous';
el.onended = () => { if (this.onEnded) this.onEnded(); };
this.mediaEl = el;
const src = this.ctx.createMediaElementSource(el);
src.connect(this.analyser);
src.connect(this.outGain); // file audio is audible
src.connect(this.recordDest); // and captured by the recorder
this.sourceNode = src;
this.mode = 'file';
@@ -78,7 +100,8 @@ class AudioEngine {
this.stream = stream;
const src = this.ctx.createMediaStreamSource(stream);
src.connect(this.analyser); // analyser only — no output
src.connect(this.analyser); // analyser only — no output (avoid feedback)
src.connect(this.recordDest); // but capture it when recording
this.sourceNode = src;
this.mode = 'input';
}
@@ -90,6 +113,25 @@ class AudioEngine {
return devices.filter(d => d.kind === 'audioinput');
}
async listOutputDevices() {
try { await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); } catch (e) {}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(d => d.kind === 'audiooutput');
}
// Route the file/playback audio to a specific output device (speaker).
// Uses AudioContext.setSinkId (Chromium 110+); empty id = system default.
async setOutputDevice(deviceId) {
if (typeof this.ctx.setSinkId !== 'function') return false;
try {
await this.ctx.setSinkId(deviceId || '');
return true;
} catch (e) {
console.warn('setSinkId fallito:', e);
return false;
}
}
togglePlay() {
if (!this.mediaEl) return null;
if (this.mediaEl.paused) { this.mediaEl.play(); return true; }
@@ -115,10 +157,10 @@ class AudioEngine {
t = t / ((trebEnd - midEnd) * 255);
const g = this.gain;
// Smooth toward new values (attack/release) and apply user gain.
this.bass = this._smooth(this.bass, Math.min(1, b * 1.6 * g), 0.5, 0.12);
this.mid = this._smooth(this.mid, Math.min(1, m * 2.2 * g), 0.5, 0.12);
this.treble = this._smooth(this.treble, Math.min(1, t * 3.0 * g), 0.6, 0.15);
// Smooth toward new values (attack/release), applying master + per-band gain.
this.bass = this._smooth(this.bass, Math.min(1, b * 1.6 * g * this.bassGain), 0.5, 0.12);
this.mid = this._smooth(this.mid, Math.min(1, m * 2.2 * g * this.midGain), 0.5, 0.12);
this.treble = this._smooth(this.treble, Math.min(1, t * 3.0 * g * this.trebleGain), 0.6, 0.15);
this.level = this._smooth(this.level, Math.min(1, (b + m + t) / 3 * 2.0 * g), 0.4, 0.1);
// Beat detection: bass spike above running average.
@@ -131,6 +173,20 @@ class AudioEngine {
this.beat *= 0.86; // decay
}
// Log-spaced spectrum bands (for VU-meter style effects).
const NB = this.NB;
const minBin = 1;
for (let i = 0; i < NB; i++) {
const lo = Math.floor(minBin * Math.pow(bins / minBin, i / NB));
let hi = Math.floor(minBin * Math.pow(bins / minBin, (i + 1) / NB));
if (hi <= lo) hi = lo + 1;
let sum = 0;
for (let k = lo; k < hi; k++) sum += this.freq[k];
let val = sum / ((hi - lo) * 255);
val = Math.min(1, val * (1.4 + i * 0.06) * g); // tilt up the highs + user gain
this.spectrum[i] = this.spectrum[i] * 0.55 + val * 0.45; // smooth
}
return this;
}
@@ -140,7 +196,7 @@ class AudioEngine {
}
get values() {
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat };
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat, spectrum: this.spectrum };
}
}
+222 -77
View File
@@ -10,100 +10,245 @@
<body class="control">
<header id="topbar">
<strong>🎛 DJ Visualizer</strong>
<span id="status"><span id="fps">–</span> · <span id="scene-name">Frattale</span></span>
<span id="status"><span id="fps">–</span> · <span id="scene-name">—</span></span>
</header>
<main id="panel-grid">
<nav id="tabs">
<button data-tab="audio" class="active">🎵 Audio</button>
<button data-tab="effetti">🌀 Effetti</button>
<button data-tab="pad">🎹 Pad</button>
<button data-tab="immagini">🖼 Immagini</button>
<button data-tab="testo">🔤 Testo</button>
<button data-tab="registra">🔴 Registra</button>
<button data-tab="schermo">🖥 Schermo</button>
</nav>
<section>
<label>Sorgente audio</label>
<div class="row">
<button id="btn-file">📂 File…</button>
<button id="btn-play" disabled>⏸ Pausa</button>
</div>
<input type="file" id="file-input" accept="audio/*" hidden />
<div class="row">
<main id="content">
<!-- ============ AUDIO ============ -->
<div class="tab-panel active" data-tab="audio">
<section>
<label>Sorgente audio</label>
<button id="btn-play" class="wide" disabled>⏸ Pausa</button>
<label>Input live</label>
<select id="device-select"><option value="">— Input live (mic/line/BlackHole) —</option></select>
</div>
<button id="btn-use-input" class="wide">🎤 Usa input selezionato</button>
</section>
<button id="btn-use-input" class="wide">🎤 Usa input selezionato</button>
<label>Uscita audio (casse)</label>
<select id="output-select"><option value="">— Uscita predefinita —</option></select>
</section>
<section>
<label>Schermo di output</label>
<select id="display-select"></select>
<div class="row">
<button id="btn-display">📺 Manda al monitor</button>
<button id="btn-fullscreen">⛶ Fullscreen</button>
</div>
</section>
<section class="meters">
<label>Livelli</label>
<div class="meter"><span>BASS</span><div class="bar"><i id="m-bass"></i></div></div>
<div class="meter"><span>MID</span><div class="bar"><i id="m-mid"></i></div></div>
<div class="meter"><span>HIGH</span><div class="bar"><i id="m-treble"></i></div></div>
</section>
<section class="span2">
<label>Scena</label>
<div id="scene-buttons" class="scene-grid"></div>
</section>
<section class="span2">
<label>Playlist <span id="playlist-count">0 brani</span></label>
<div class="row">
<button id="btn-add-tracks">➕ Aggiungi brani</button>
<button id="btn-clear-playlist">🗑 Svuota</button>
<label class="chk"><input type="checkbox" id="repeat-playlist" /> Ripeti</label>
</div>
<input type="file" id="tracks-input" accept="audio/*" multiple hidden />
<ol id="playlist" class="playlist"></ol>
<div class="hint-mini">Trascina ⠿ per riordinare · ⌨ tasto rapido · ▶/⏸ avvia/pausa</div>
</section>
<section>
<label>Reattività <span id="gain-val">1.0×</span></label>
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
<label>Velocità <span id="speed-val">1.0×</span></label>
<input type="range" id="speed" min="0" max="3" step="0.05" value="1" />
</section>
<section>
<label>Reattività <span id="gain-val">1.0×</span></label>
<input type="range" id="gain" min="0" max="3" step="0.05" value="1" />
<label>Velocità <span id="speed-val">1.0×</span></label>
<input type="range" id="speed" min="0" max="3" step="0.05" value="1" />
</section>
<section class="meters">
<label>Livelli</label>
<div class="meter"><span>BASS</span><div class="bar"><i id="m-bass"></i></div></div>
<div class="meter"><span>MID</span><div class="bar"><i id="m-mid"></i></div></div>
<div class="meter"><span>HIGH</span><div class="bar"><i id="m-treble"></i></div></div>
</section>
<section>
<label>Equalizzatore visual</label>
<label class="band band-bass">Bassi <span id="bass-gain-val">1.0×</span></label>
<input type="range" id="bass-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-mid">Medi <span id="mid-gain-val">1.0×</span></label>
<input type="range" id="mid-gain" min="0" max="3" step="0.05" value="1" />
<label class="band band-treble">Alti <span id="treble-gain-val">1.0×</span></label>
<input type="range" id="treble-gain" min="0" max="3" step="0.05" value="1" />
</section>
</div>
<section>
<label>Immagini overlay</label>
<div class="row">
<button id="btn-images">🖼 Carica…</button>
<button id="btn-images-clear">Pulisci</button>
</div>
<input type="file" id="image-input" accept="image/*" multiple hidden />
<div class="row">
<label class="chk"><input type="checkbox" id="img-slideshow" /> Slideshow</label>
<label class="chk"><input type="checkbox" id="img-beat" checked /> Pulsa beat</label>
</div>
<label>Dimensione <span id="img-size-val">70%</span></label>
<input type="range" id="img-size" min="10" max="100" step="1" value="70" />
<label>Durata slideshow <span id="img-interval-val">5.0s</span></label>
<input type="range" id="img-interval" min="1" max="20" step="0.5" value="5" />
<label>Fusione</label>
<select id="img-blend">
<option value="screen">screen (luminoso)</option>
<option value="normal">normale</option>
<option value="overlay">overlay</option>
<option value="multiply">multiply</option>
<option value="lighten">lighten</option>
</select>
</section>
<!-- ============ EFFETTI ============ -->
<div class="tab-panel" data-tab="effetti">
<section class="span2" id="effects-section">
<label>Libreria effetti <span id="effects-count"></span></label>
<div class="row">
<select id="effect-family"><option value="-1">Tutte le famiglie</option></select>
<input type="text" id="effect-search" class="textfield" placeholder="Cerca effetto…" />
</div>
<div id="effect-list" class="effect-list"></div>
</section>
<section>
<label>Testo scorrevole</label>
<input type="text" id="ticker-text" class="textfield" placeholder="Es. NOME DJ · EVENTO" />
<div class="row">
<label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label>
<select id="ticker-pos">
<option value="bottom">Basso</option>
<option value="top">Alto</option>
<option value="middle">Centro</option>
<section class="span2" id="svg-section">
<label>Effetti SVG / immagini</label>
<select id="svg-select"><option value="-1">— Scegli una sagoma… —</option></select>
<button id="btn-svg-load" class="wide">📂 Carica un SVG/immagine…</button>
<input type="file" id="svg-input" accept="image/svg+xml,image/*" hidden />
<div class="hint-mini">Scegli una sagoma → diventa un effetto reattivo. Per cambiare colori/stile filtra la libreria su “SVG/Immagine”.</div>
</section>
<section class="span2" id="effect-seq-section">
<label>Sequenza effetti <span id="seq-count">0 in sequenza</span></label>
<div class="row">
<button id="btn-seq-add">➕ Aggiungi corrente</button>
<button id="btn-seq-clear">🗑 Svuota</button>
</div>
<div class="row">
<label class="chk"><input type="checkbox" id="seq-auto" /> Auto-cambio</label>
<label class="chk"><input type="checkbox" id="seq-beat" /> Sul beat</label>
<label class="chk"><input type="checkbox" id="seq-shuffle" /> Shuffle</label>
</div>
<label>Intervallo auto-cambio <span id="seq-interval-val">8.0s</span></label>
<input type="range" id="seq-interval" min="1" max="60" step="0.5" value="8" />
<ol id="effect-seq" class="playlist"></ol>
<div class="hint-mini">Clic in libreria = applica · ➕ = in sequenza · ⌨ tasto rapido · 1-9 = primi 9</div>
</section>
</div>
<!-- ============ PAD ============ -->
<div class="tab-panel" data-tab="pad">
<section class="span2">
<label>Pad — 20 brani al volo (5×4, salvati automaticamente)</label>
<div class="row">
<button id="btn-pad-stop">⏹ Stop</button>
<button id="btn-pad-clear">🗑 Svuota tutti</button>
</div>
<div id="pad-grid" class="pad-grid"></div>
<input type="file" id="pad-input" accept="audio/*" hidden />
<div class="hint-mini">Clic su un pad vuoto (o trascina un brano) per assegnarlo · clic su un pad pieno per avviarlo · premendone un altro ferma quello in corso · <b>tasto destro</b> per ricaricare · ✕ per svuotare.</div>
</section>
</div>
<!-- ============ IMMAGINI ============ -->
<div class="tab-panel" data-tab="immagini">
<section>
<label>Immagini overlay</label>
<div class="row">
<button id="btn-images">🖼 Carica…</button>
<button id="btn-images-clear">Pulisci</button>
</div>
<input type="file" id="image-input" accept="image/*" multiple hidden />
<div class="row">
<label class="chk"><input type="checkbox" id="img-slideshow" /> Slideshow</label>
<label class="chk"><input type="checkbox" id="img-beat" checked /> Pulsa beat</label>
</div>
<label>Dimensione <span id="img-size-val">70%</span></label>
<input type="range" id="img-size" min="10" max="100" step="1" value="70" />
<label>Durata slideshow <span id="img-interval-val">5.0s</span></label>
<input type="range" id="img-interval" min="1" max="20" step="0.5" value="5" />
<label>Fusione</label>
<select id="img-blend">
<option value="screen">screen (luminoso)</option>
<option value="normal">normale</option>
<option value="overlay">overlay</option>
<option value="multiply">multiply</option>
<option value="lighten">lighten</option>
</select>
</div>
<label>Velocità testo <span id="ticker-speed-val">1.0×</span></label>
<input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" />
</section>
</section>
<section class="logo-ctl" data-logo="0">
<label>Logo 1</label>
<div class="row">
<button class="logo-load" data-logo="0">🖼 Carica</button>
<button class="logo-clear" data-logo="0">Rimuovi</button>
</div>
<input type="file" class="logo-input" data-logo="0" accept="image/*" hidden />
<label>Orizzontale <span class="logo-x-val">50%</span></label>
<input type="range" class="logo-x" data-logo="0" min="0" max="100" step="0.5" value="50" />
<label>Verticale <span class="logo-y-val">50%</span></label>
<input type="range" class="logo-y" data-logo="0" min="0" max="100" step="0.5" value="50" />
<label>Dimensione <span class="logo-size-val">20%</span></label>
<input type="range" class="logo-size" data-logo="0" min="2" max="80" step="0.5" value="20" />
<label>Opacità <span class="logo-op-val">100%</span></label>
<input type="range" class="logo-op" data-logo="0" min="0" max="100" step="1" value="100" />
</section>
<section class="logo-ctl" data-logo="1">
<label>Logo 2</label>
<div class="row">
<button class="logo-load" data-logo="1">🖼 Carica</button>
<button class="logo-clear" data-logo="1">Rimuovi</button>
</div>
<input type="file" class="logo-input" data-logo="1" accept="image/*" hidden />
<label>Orizzontale <span class="logo-x-val">50%</span></label>
<input type="range" class="logo-x" data-logo="1" min="0" max="100" step="0.5" value="50" />
<label>Verticale <span class="logo-y-val">50%</span></label>
<input type="range" class="logo-y" data-logo="1" min="0" max="100" step="0.5" value="50" />
<label>Dimensione <span class="logo-size-val">20%</span></label>
<input type="range" class="logo-size" data-logo="1" min="2" max="80" step="0.5" value="20" />
<label>Opacità <span class="logo-op-val">100%</span></label>
<input type="range" class="logo-op" data-logo="1" min="0" max="100" step="1" value="100" />
</section>
</div>
<!-- ============ TESTO ============ -->
<div class="tab-panel" data-tab="testo">
<section class="span2">
<label>Testo scorrevole</label>
<input type="text" id="ticker-text" class="textfield" placeholder="Es. NOME DJ · EVENTO" />
<div class="row">
<label class="chk"><input type="checkbox" id="ticker-on" /> Mostra</label>
<select id="ticker-pos">
<option value="bottom">Basso</option>
<option value="top">Alto</option>
<option value="middle">Centro</option>
</select>
</div>
<label>Velocità testo <span id="ticker-speed-val">1.0×</span></label>
<input type="range" id="ticker-speed" min="0.2" max="4" step="0.1" value="1" />
</section>
</div>
<!-- ============ REGISTRA ============ -->
<div class="tab-panel" data-tab="registra">
<section class="span2">
<label>Registrazione output → MP4</label>
<div class="rec-status">
<span id="rec-dot" class="off"></span>
<span id="rec-label">Pronto</span>
<span id="rec-time">00:00</span>
</div>
<label>Formato</label>
<select id="rec-aspect">
<option value="1920x1080">16:9 orizzontale (1920×1080)</option>
<option value="1080x1920">9:16 verticale (1080×1920)</option>
<option value="1080x1080">1:1 quadrato (1080×1080)</option>
<option value="1440x1080">4:3 (1440×1080)</option>
<option value="1080x1440">3:4 verticale (1080×1440)</option>
<option value="2560x1080">21:9 cinema (2560×1080)</option>
</select>
<button id="btn-rec" class="wide">🔴 Avvia registrazione</button>
<button id="btn-rec-folder" class="wide">📁 Apri cartella registrazioni</button>
<div class="hint-mini">Registra ciò che vedi sull'output + l'audio (file o input live). Salvataggio in <b>Filmati ▸ DJ Visualizer</b>. La conversione in MP4 avviene allo stop.</div>
</section>
</div>
<!-- ============ SCHERMO ============ -->
<div class="tab-panel" data-tab="schermo">
<section class="span2">
<label>Schermo di output</label>
<select id="display-select"></select>
<div class="row">
<button id="btn-display">📺 Manda al monitor</button>
<button id="btn-fullscreen">⛶ Fullscreen</button>
</div>
</section>
</div>
</main>
<footer id="hints">
<kbd>1</kbd>–<kbd>8</kbd> scene · <kbd>F</kbd> fullscreen output · <kbd>O</kbd> file ·
<kbd>← →</kbd> immagini · <kbd>Spazio</kbd> play/pausa
<kbd>1</kbd>–<kbd>9</kbd> effetti in sequenza · <kbd>F</kbd> fullscreen · <kbd>O</kbd> aggiungi brani ·
<kbd>← →</kbd> immagini · <kbd>Spazio</kbd> play/pausa · tasti rapidi personalizzabili
</footer>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="control.js"></script>
</body>
</html>
+564 -41
View File
@@ -5,44 +5,316 @@ const $ = (s) => document.querySelector(s);
const send = (msg) => djv.send(msg);
const filePath = (f) => djv.pathForFile(f);
// ---------------------------------------------------------------- scenes
const sceneNames = (window.SHADERS && window.SHADERS.names) ||
['Frattale', 'Plasma', 'Tunnel', 'Vortice', 'Onde', 'Cellule', 'Iperspazio', 'Specchi'];
const sceneContainer = $('#scene-buttons');
sceneNames.forEach((name, i) => {
const b = document.createElement('button');
b.dataset.scene = i;
b.textContent = (i + 1) + ' · ' + name;
if (i === 0) b.classList.add('active');
sceneContainer.appendChild(b);
// ---------------------------------------------------------------- effects
const EFFECTS = window.EFFECTS;
let currentEffect = EFFECTS.list[0];
function applyEffect(effect) {
currentEffect = effect;
send({ type: 'effect', effect });
$('#scene-name').textContent = effect.name;
renderLibrary();
}
// --- Library browser (hundreds of presets, filterable) ---
const familySel = $('#effect-family');
EFFECTS.families.forEach((name, i) => {
const o = document.createElement('option'); o.value = i; o.textContent = name;
familySel.appendChild(o);
});
function setScene(i) {
if (i < 0 || i >= sceneNames.length) return;
send({ type: 'scene', index: i });
$('#scene-name').textContent = sceneNames[i];
document.querySelectorAll('#scene-buttons button').forEach((b, idx) =>
b.classList.toggle('active', idx === i));
const LIB_CAP = 300; // max items rendered at once
function filteredEffects() {
const fam = parseInt($('#effect-family').value, 10);
const q = $('#effect-search').value.trim().toLowerCase();
const out = [];
for (let i = 0; i < EFFECTS.list.length; i++) {
const e = EFFECTS.list[i];
if (fam >= 0 && e.family !== fam) continue;
if (q && e.name.toLowerCase().indexOf(q) < 0) continue;
out.push(e);
if (out.length >= LIB_CAP) break;
}
return out;
}
sceneContainer.addEventListener('click', (e) => {
const b = e.target.closest('button');
if (b) setScene(parseInt(b.dataset.scene, 10));
function renderLibrary() {
const list = $('#effect-list');
const items = filteredEffects();
list.innerHTML = '';
items.forEach((e) => {
const row = document.createElement('div');
row.className = 'fx' + (e === currentEffect ? ' active' : '');
row.innerHTML = '<span class="fx-name">' + e.name + '</span>' +
'<button class="fx-add" title="Aggiungi alla sequenza">➕</button>';
row.querySelector('.fx-name').addEventListener('click', () => applyEffect(e));
row.querySelector('.fx-add').addEventListener('click', () => addToSequence(e));
list.appendChild(row);
});
$('#effects-count').textContent = EFFECTS.count + ' preset' +
(items.length >= LIB_CAP ? ' · mostro i primi ' + LIB_CAP : '');
}
$('#effect-family').addEventListener('change', () => {
renderLibrary();
// Switching to a specific family applies its first preset, so the
// visualization changes category immediately (e.g. away from an SVG).
const fam = parseInt($('#effect-family').value, 10);
if (fam >= 0) {
const first = filteredEffects()[0];
if (first) applyEffect(first);
}
});
$('#effect-search').addEventListener('input', renderLibrary);
// --- SVG / image as a custom effect source ---
function applySvgFamily() {
const fam = EFFECTS.families.indexOf('SVG/Immagine');
const eff = EFFECTS.list.find(e => e.family === fam);
if (eff) applyEffect(eff);
}
function useSvg(dataUrl) {
send({ type: 'svg', dataUrl });
applySvgFamily();
}
const svgSelect = $('#svg-select');
let builtinSvgs = [];
(async () => {
builtinSvgs = (djv.listBuiltinSvgs ? await djv.listBuiltinSvgs() : []) || [];
builtinSvgs
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((s) => {
const o = document.createElement('option');
o.value = builtinSvgs.indexOf(s);
o.textContent = s.name;
svgSelect.appendChild(o);
});
})();
svgSelect.addEventListener('change', (e) => {
const i = parseInt(e.target.value, 10);
if (i >= 0 && builtinSvgs[i]) useSvg(builtinSvgs[i].dataUrl);
});
$('#btn-svg-load').addEventListener('click', () => $('#svg-input').click());
$('#svg-input').addEventListener('change', (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => useSvg(r.result); // data: URL
r.readAsDataURL(f);
e.target.value = '';
});
// --- Effect sequence (playlist of effects with auto-cycle) ---
let sequence = []; // { effect, key }
let seqIndex = -1;
let seqCapturing = -1;
let seqDragFrom = -1;
let autoCycle = false, onBeat = false, shuffle = false, seqMs = 8000;
let autoTimer = null, lastBeatAdvance = 0;
function addToSequence(effect) { sequence.push({ effect, key: null }); renderSequence(); }
function applySeqIndex(i) {
if (i < 0 || i >= sequence.length) return;
seqIndex = i;
applyEffect(sequence[i].effect);
renderSequence();
}
function nextInSequence() {
if (!sequence.length) return;
const n = shuffle ? Math.floor(Math.random() * sequence.length) : (seqIndex + 1) % sequence.length;
applySeqIndex(n);
}
function removeSeq(i) {
sequence.splice(i, 1);
if (i === seqIndex) seqIndex = -1; else if (i < seqIndex) seqIndex--;
renderSequence();
}
function moveSeq(from, to) {
if (to < 0 || to >= sequence.length) return;
const [it] = sequence.splice(from, 1);
sequence.splice(to, 0, it);
if (seqIndex === from) seqIndex = to;
renderSequence();
}
function renderSequence() {
const ol = $('#effect-seq');
ol.innerHTML = '';
sequence.forEach((s, i) => {
const li = document.createElement('li');
li.className = 'track' + (i === seqIndex ? ' playing' : '');
li.draggable = true;
li.innerHTML =
'<span class="grip">⠿</span>' +
'<span class="tname" title="' + s.effect.name.replace(/"/g, '&quot;') + '">' +
(i + 1) + '. ' + s.effect.name + '</span>' +
'<button class="key-btn" title="Tasto rapido">' +
(seqCapturing === i ? '…' : (s.key ? s.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="play-btn" title="Applica">▶</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>';
li.querySelector('.play-btn').addEventListener('click', () => applySeqIndex(i));
li.querySelector('.del-btn').addEventListener('click', () => removeSeq(i));
li.querySelector('.key-btn').addEventListener('click', () => {
seqCapturing = seqCapturing === i ? -1 : i; renderSequence();
});
li.addEventListener('dragstart', () => { seqDragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { seqDragFrom = -1; li.classList.remove('dragging'); });
li.addEventListener('dragover', (e) => e.preventDefault());
li.addEventListener('drop', (e) => { e.preventDefault(); if (seqDragFrom >= 0 && seqDragFrom !== i) moveSeq(seqDragFrom, i); });
ol.appendChild(li);
});
$('#seq-count').textContent = sequence.length + ' in sequenza';
}
function restartAuto() {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
if (autoCycle && !onBeat) autoTimer = setInterval(nextInSequence, seqMs);
}
$('#btn-seq-add').addEventListener('click', () => addToSequence(currentEffect));
$('#btn-seq-clear').addEventListener('click', () => { sequence = []; seqIndex = -1; renderSequence(); });
$('#seq-auto').addEventListener('change', (e) => { autoCycle = e.target.checked; restartAuto(); });
$('#seq-beat').addEventListener('change', (e) => { onBeat = e.target.checked; restartAuto(); });
$('#seq-shuffle').addEventListener('change', (e) => { shuffle = e.target.checked; });
$('#seq-interval').addEventListener('input', (e) => {
seqMs = parseFloat(e.target.value) * 1000;
$('#seq-interval-val').textContent = parseFloat(e.target.value).toFixed(1) + 's';
restartAuto();
});
// ---------------------------------------------------------------- audio
$('#btn-file').addEventListener('click', () => $('#file-input').click());
$('#file-input').addEventListener('change', (e) => {
const f = e.target.files[0];
const p = f && filePath(f);
if (p) {
send({ type: 'loadFile', path: p });
$('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa';
}
function togglePlayPause() {
// If the track finished, restart it; otherwise pause/resume.
if (ended && currentIndex >= 0) playIndex(currentIndex);
else send({ type: 'togglePlay' });
}
$('#btn-play').addEventListener('click', togglePlayPause);
// ---------------------------------------------------------------- playlist
let playlist = []; // { path, name, key }
let currentIndex = -1;
let repeat = false;
let isPlaying = false; // whether the current track is playing
let ended = false; // true when the current track reached its end
let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback
let capturingFor = -1; // index of the track awaiting a hotkey, or -1
const baseName = (p) => p.split('/').pop();
function addTracks(items) {
// items: array of { path, name }
const start = playlist.length;
items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null }); });
renderPlaylist();
// Auto-start the first added track if nothing is playing yet.
if (currentIndex < 0 && playlist.length > start) playIndex(start);
}
function playIndex(i) {
if (i < 0 || i >= playlist.length) return;
currentIndex = i;
isPlaying = true;
ended = false;
playbackOwner = 'playlist';
activePad = -1; padPlaying = false; renderPads();
send({ type: 'playTrack', path: playlist[i].path });
$('#btn-play').disabled = false;
$('#btn-play').textContent = '⏸ Pausa';
renderPlaylist();
}
function nextTrack() {
if (!playlist.length) return false;
let n = currentIndex + 1;
if (n >= playlist.length) { if (!repeat) return false; n = 0; }
playIndex(n);
return true;
}
function removeTrack(i) {
playlist.splice(i, 1);
if (i === currentIndex) currentIndex = -1;
else if (i < currentIndex) currentIndex--;
renderPlaylist();
}
function moveTrack(from, to) {
if (to < 0 || to >= playlist.length) return;
const [item] = playlist.splice(from, 1);
playlist.splice(to, 0, item);
if (currentIndex === from) currentIndex = to;
else if (from < currentIndex && to >= currentIndex) currentIndex--;
else if (from > currentIndex && to <= currentIndex) currentIndex++;
renderPlaylist();
}
let dragFrom = -1;
function renderPlaylist() {
const ol = $('#playlist');
ol.innerHTML = '';
playlist.forEach((tr, i) => {
const li = document.createElement('li');
li.className = 'track' + (i === currentIndex ? ' playing' : '');
li.draggable = true;
li.dataset.index = i;
const isCur = i === currentIndex;
const playIcon = (isCur && isPlaying) ? '⏸' : '▶';
li.innerHTML =
'<span class="grip">⠿</span>' +
'<span class="tname" title="' + tr.name.replace(/"/g, '&quot;') + '">' + tr.name + '</span>' +
'<button class="key-btn" title="Assegna tasto rapido">' +
(capturingFor === i ? '…' : (tr.key ? tr.key.toUpperCase() : '⌨')) + '</button>' +
'<button class="play-btn" title="' + (isCur && isPlaying ? 'Pausa' : 'Avvia') + '">' + playIcon + '</button>' +
'<button class="del-btn" title="Rimuovi">✕</button>';
// On the current track the button pauses/resumes/replays; on others it starts that track.
li.querySelector('.play-btn').addEventListener('click', () => {
if (i === currentIndex) togglePlayPause();
else playIndex(i);
});
li.querySelector('.del-btn').addEventListener('click', () => removeTrack(i));
li.querySelector('.key-btn').addEventListener('click', () => {
capturingFor = capturingFor === i ? -1 : i;
renderPlaylist();
});
// Drag to reorder.
li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); });
li.addEventListener('dragend', () => { dragFrom = -1; li.classList.remove('dragging'); });
li.addEventListener('dragover', (e) => e.preventDefault());
li.addEventListener('drop', (e) => {
e.preventDefault();
if (dragFrom >= 0 && dragFrom !== i) moveTrack(dragFrom, i);
});
ol.appendChild(li);
});
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
}
$('#btn-add-tracks').addEventListener('click', () => $('#tracks-input').click());
$('#tracks-input').addEventListener('change', (e) => {
const items = [...e.target.files].map(f => ({ path: filePath(f), name: f.name }));
if (items.length) addTracks(items);
e.target.value = ''; // allow re-adding the same file
});
$('#btn-play').addEventListener('click', () => send({ type: 'togglePlay' }));
$('#btn-clear-playlist').addEventListener('click', () => {
playlist = []; currentIndex = -1; renderPlaylist();
});
$('#repeat-playlist').addEventListener('change', (e) => { repeat = e.target.checked; });
$('#btn-use-input').addEventListener('click', () =>
send({ type: 'useInput', deviceId: $('#device-select').value || null }));
$('#output-select').addEventListener('change', (e) =>
send({ type: 'outputDevice', deviceId: e.target.value || '' }));
// Per-band visual intensity (Bass / Mid / Treble).
[['bass', 'bass-gain'], ['mid', 'mid-gain'], ['treble', 'treble-gain']].forEach(([band, id]) => {
$('#' + id).addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
$('#' + id + '-val').textContent = v.toFixed(1) + '×';
send({ type: 'bandGain', band, value: v });
});
});
// ---------------------------------------------------------------- parameters
$('#gain').addEventListener('input', (e) => {
@@ -105,6 +377,157 @@ $('#btn-display').addEventListener('click', () => {
});
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
// ---------------------------------------------------------------- recording
let recOn = false, recTimer = null, recStartedAt = 0;
function fmtTime(s) {
const m = Math.floor(s / 60), ss = s % 60;
return String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0');
}
function recTick() { $('#rec-time').textContent = fmtTime(Math.floor((Date.now() - recStartedAt) / 1000)); }
function stopRecTimer() { if (recTimer) { clearInterval(recTimer); recTimer = null; } }
$('#btn-rec').addEventListener('click', () => {
if (!recOn) {
send({ type: 'recStart' });
} else {
const [w, h] = $('#rec-aspect').value.split('x').map(Number);
send({ type: 'recStop', w, h });
stopRecTimer();
$('#rec-label').textContent = 'Conversione MP4…';
$('#btn-rec').disabled = true;
}
});
$('#btn-rec-folder').addEventListener('click', () => djv.openRecordingsFolder());
// ---------------------------------------------------------------- pad bank
const PAD_COUNT = 20; // 5 × 4
let pads = new Array(PAD_COUNT).fill(null); // each: { path, name } or null
let activePad = -1, padPlaying = false, padLoadTarget = -1;
const padGrid = $('#pad-grid');
function savePadState() { if (djv.savePads) djv.savePads(pads); }
function renderPads() {
if (!padGrid) return;
padGrid.innerHTML = '';
pads.forEach((p, i) => {
const d = document.createElement('div');
d.className = 'pad' + (p ? ' filled' : '') + (i === activePad && padPlaying ? ' active' : '');
d.dataset.i = i;
d.innerHTML = '<span class="pad-num">' + (i + 1) + '</span>' +
(p ? '<span class="pad-name">' + p.name.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</span><button class="pad-x" title="Svuota">✕</button>'
: '<span class="pad-plus">+</span>');
padGrid.appendChild(d);
});
}
function assignPad(i, item) { pads[i] = item; renderPads(); savePadState(); }
function loadPadFile(i) { padLoadTarget = i; $('#pad-input').click(); }
function playPad(i) {
const p = pads[i];
if (!p) { loadPadFile(i); return; }
if (i === activePad && padPlaying) { // re-press the active pad = stop
send({ type: 'togglePlay' });
padPlaying = false; renderPads();
return;
}
playbackOwner = 'pad';
activePad = i; padPlaying = true;
// The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist();
send({ type: 'playTrack', path: p.path });
renderPads();
}
function clearPad(i) {
pads[i] = null;
if (i === activePad) { activePad = -1; padPlaying = false; }
renderPads();
savePadState();
}
padGrid.addEventListener('click', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
const i = parseInt(pad.dataset.i, 10);
if (e.target.closest('.pad-x')) { e.stopPropagation(); clearPad(i); return; }
playPad(i);
});
padGrid.addEventListener('contextmenu', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
e.preventDefault();
loadPadFile(parseInt(pad.dataset.i, 10));
});
padGrid.addEventListener('dragover', (e) => { if (e.target.closest('.pad')) { e.preventDefault(); e.stopPropagation(); } });
padGrid.addEventListener('drop', (e) => {
const pad = e.target.closest('.pad');
if (!pad) return;
e.preventDefault(); e.stopPropagation();
const f = [...e.dataTransfer.files].find(x => x.type.startsWith('audio/'));
const p = f && filePath(f);
if (p) assignPad(parseInt(pad.dataset.i, 10), { path: p, name: f.name });
});
$('#pad-input').addEventListener('change', (e) => {
const f = e.target.files[0];
const p = f && filePath(f);
if (p && padLoadTarget >= 0) assignPad(padLoadTarget, { path: p, name: f.name });
padLoadTarget = -1; e.target.value = '';
});
$('#btn-pad-stop').addEventListener('click', () => {
if (padPlaying) { send({ type: 'togglePlay' }); padPlaying = false; renderPads(); }
});
$('#btn-pad-clear').addEventListener('click', () => {
pads = new Array(PAD_COUNT).fill(null); activePad = -1; padPlaying = false; renderPads(); savePadState();
});
// Load saved pad assignments on startup.
(async () => {
try {
const saved = djv.loadPads ? await djv.loadPads() : null;
if (Array.isArray(saved)) for (let i = 0; i < PAD_COUNT; i++) pads[i] = saved[i] || null;
} catch (e) { /* ignore */ }
renderPads();
})();
// ---------------------------------------------------------------- tabs
document.querySelectorAll('#tabs button').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.tab;
document.querySelectorAll('#tabs button').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.dataset.tab === t));
});
});
// ---------------------------------------------------------------- logos
document.querySelectorAll('.logo-load').forEach(b => b.addEventListener('click', () =>
document.querySelector('.logo-input[data-logo="' + b.dataset.logo + '"]').click()));
document.querySelectorAll('.logo-input').forEach(inp => inp.addEventListener('change', (e) => {
const i = parseInt(inp.dataset.logo, 10);
const p = e.target.files[0] && filePath(e.target.files[0]);
if (p) send({ type: 'logo', index: i, path: p });
inp.value = '';
}));
document.querySelectorAll('.logo-clear').forEach(b => b.addEventListener('click', () =>
send({ type: 'logo', index: parseInt(b.dataset.logo, 10), path: null })));
function bindLogoSliders(cls, valCls, type, fmt, xform) {
document.querySelectorAll('.' + cls).forEach(s => s.addEventListener('input', (e) => {
const i = parseInt(s.dataset.logo, 10);
const v = parseFloat(e.target.value);
s.closest('.logo-ctl').querySelector('.' + valCls).textContent = fmt(v);
send({ type, index: i, value: xform ? xform(v) : v });
}));
}
bindLogoSliders('logo-x', 'logo-x-val', 'logoX', v => v + '%');
bindLogoSliders('logo-y', 'logo-y-val', 'logoY', v => v + '%');
bindLogoSliders('logo-size', 'logo-size-val', 'logoSize', v => v + '%');
bindLogoSliders('logo-op', 'logo-op-val', 'logoOpacity', v => v + '%', v => v / 100);
// ---------------------------------------------------------------- drag & drop
window.addEventListener('dragover', (e) => { e.preventDefault(); document.body.classList.add('dragover'); });
window.addEventListener('dragleave', (e) => { if (e.relatedTarget === null) document.body.classList.remove('dragover'); });
@@ -112,28 +535,59 @@ window.addEventListener('drop', (e) => {
e.preventDefault();
document.body.classList.remove('dragover');
const files = [...e.dataTransfer.files];
const audioFile = files.find(f => f.type.startsWith('audio/'));
const audioItems = files.filter(f => f.type.startsWith('audio/'))
.map(f => ({ path: filePath(f), name: f.name }));
const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean);
const audioPath = audioFile && filePath(audioFile);
if (audioPath) {
send({ type: 'loadFile', path: audioPath });
$('#btn-play').disabled = false;
}
if (audioItems.length) addTracks(audioItems);
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
});
// ---------------------------------------------------------------- keyboard
window.addEventListener('keydown', (e) => {
// Assigning a hotkey to a playlist track.
if (capturingFor >= 0) {
e.preventDefault();
if (e.key !== 'Escape' && e.key.length === 1) {
const k = e.key.toLowerCase();
playlist.forEach(t => { if (t.key === k) t.key = null; }); // keys are unique
playlist[capturingFor].key = k;
}
capturingFor = -1;
renderPlaylist();
return;
}
// Assigning a hotkey to a sequence effect.
if (seqCapturing >= 0) {
e.preventDefault();
if (e.key !== 'Escape' && e.key.length === 1) {
const k = e.key.toLowerCase();
sequence.forEach(s => { if (s.key === k) s.key = null; });
sequence[seqCapturing].key = k;
}
seqCapturing = -1;
renderSequence();
return;
}
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
if (e.key >= '1' && e.key <= '9') { setScene(parseInt(e.key, 10) - 1); return; }
switch (e.key.toLowerCase()) {
const lk = e.key.toLowerCase();
// Track hotkeys take priority, then effect hotkeys.
const hk = playlist.findIndex(t => t.key === lk);
if (hk >= 0) { playIndex(hk); return; }
const ek = sequence.findIndex(s => s.key === lk);
if (ek >= 0) { applySeqIndex(ek); return; }
// Number keys 1-9 apply the first nine sequence slots.
if (e.key >= '1' && e.key <= '9') { applySeqIndex(parseInt(e.key, 10) - 1); return; }
switch (lk) {
case 'f': djv.toggleOutputFullscreen(); break;
case 'o': $('#file-input').click(); break;
case 'o': $('#tracks-input').click(); break;
case 'g': $('#image-input').click(); break;
case 'arrowright': send({ type: 'imgNext' }); break;
case 'arrowleft': send({ type: 'imgPrev' }); break;
case 't': { const cb = $('#ticker-on'); cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); break; }
case ' ': e.preventDefault(); send({ type: 'togglePlay' }); break;
case ' ': e.preventDefault(); togglePlayPause(); break;
}
});
@@ -153,6 +607,19 @@ djv.onReport((m) => {
sel.value = cur;
break;
}
case 'outputs': {
const sel = $('#output-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— Uscita predefinita —</option>';
m.list.forEach(d => {
const o = document.createElement('option');
o.value = d.deviceId;
o.textContent = d.label || 'Uscita audio';
sel.appendChild(o);
});
sel.value = cur;
break;
}
case 'meters':
$('#m-bass').style.width = (m.bass * 100).toFixed(0) + '%';
$('#m-mid').style.width = (m.mid * 100).toFixed(0) + '%';
@@ -160,8 +627,61 @@ djv.onReport((m) => {
$('#fps').textContent = m.fps.toFixed(0) + ' fps';
break;
case 'playState':
$('#btn-play').disabled = false;
$('#btn-play').textContent = m.playing ? '⏸ Pausa' : '▶ Play';
if (playbackOwner === 'pad') {
padPlaying = m.playing;
renderPads();
} else {
isPlaying = m.playing;
if (m.playing) ended = false;
$('#btn-play').disabled = false;
$('#btn-play').textContent = m.playing ? '⏸ Pausa' : '▶ Play';
renderPlaylist();
}
break;
case 'trackEnded':
if (playbackOwner === 'pad') {
padPlaying = false;
renderPads();
} else if (!nextTrack()) {
// No next track: reset to a stopped state so the button shows ▶ again.
isPlaying = false;
ended = true;
$('#btn-play').textContent = '▶ Play';
renderPlaylist();
}
break;
case 'beat':
// Auto-cycle effects on the beat (with a small guard against double-fires).
if (autoCycle && onBeat) {
const now = Date.now();
if (now - lastBeatAdvance > 250) { lastBeatAdvance = now; nextInSequence(); }
}
break;
case 'recState':
recOn = m.recording;
$('#btn-rec').disabled = false;
$('#rec-dot').className = recOn ? 'on' : 'off';
if (recOn) {
$('#btn-rec').textContent = '⏹ Ferma e salva MP4';
$('#rec-label').textContent = 'Registrazione…';
recStartedAt = Date.now(); recTick();
stopRecTimer(); recTimer = setInterval(recTick, 1000);
} else {
$('#btn-rec').textContent = '🔴 Avvia registrazione';
stopRecTimer();
}
break;
case 'recSaved':
$('#rec-label').textContent = 'Salvato ✓';
$('#btn-rec').disabled = false;
break;
case 'recError':
recOn = false; stopRecTimer();
$('#rec-dot').className = 'off';
$('#btn-rec').disabled = false;
$('#btn-rec').textContent = '🔴 Avvia registrazione';
$('#rec-label').textContent = 'Errore: ' + m.message;
console.warn('rec error:', m.message);
break;
case 'fileLoaded':
$('#btn-play').disabled = false;
@@ -173,6 +693,9 @@ djv.onReport((m) => {
});
// ---------------------------------------------------------------- init
renderLibrary();
renderSequence();
applyEffect(currentEffect); // push the starting effect to the output window
refreshDisplays();
window.addEventListener('focus', refreshDisplays);
send({ type: 'refreshDevices' });
+104
View File
@@ -0,0 +1,104 @@
// Effect catalog generator. Combines 16 shader families × color palettes ×
// style variants to produce hundreds of named presets. Shared by both windows.
// Wrapped in an IIFE so its locals don't leak into the shared global scope
// (control.js also defines an EFFECTS reference).
(function () {
const FAMILIES = [
{ name: 'Julia', scale: 1.35 },
{ name: 'Mandelbrot', scale: 1.1 },
{ name: 'Plasma', scale: 1.0 },
{ name: 'Vortice', scale: 1.0 },
{ name: 'Onde', scale: 1.0 },
{ name: 'Cellule', scale: 1.0 },
{ name: 'Iperspazio', scale: 1.0 },
{ name: 'Tunnel', scale: 1.0 },
{ name: 'Moiré', scale: 1.0 },
{ name: 'Truchet', scale: 1.0 },
{ name: 'Gyroide', scale: 1.0 },
{ name: 'Esagoni', scale: 1.0 },
{ name: 'Reticolo', scale: 1.0 },
{ name: 'Nuvole', scale: 1.0 },
{ name: 'Spirale', scale: 1.0 },
{ name: 'Cristalli', scale: 1.0 },
{ name: 'Ballerini', scale: 1.0, bgDark: true },
{ name: 'Ballerino', scale: 1.0, bgDark: true },
{ name: 'Sagome', scale: 1.0, bgDark: true },
{ name: 'SVG/Immagine', scale: 1.0, bgDark: true },
{ name: 'VU Barre', scale: 1.0, bgDark: true },
{ name: 'VU Analogico', scale: 1.0, bgDark: true },
{ name: 'VU Stereo', scale: 1.0, bgDark: true }
];
// Palettes: low colour (a) -> high colour (b), plus optional hue-cycle/sat.
const PALETTES = [
{ name: 'Neon', a: [0.05, 0.0, 0.2], b: [0.2, 1.0, 1.0], cycle: 0.02 },
{ name: 'Tramonto', a: [0.15, 0.0, 0.1], b: [1.0, 0.65, 0.1], cycle: 0.01 },
{ name: 'Oceano', a: [0.0, 0.05, 0.18], b: [0.1, 0.8, 1.0], cycle: 0.01 },
{ name: 'Foresta', a: [0.0, 0.08, 0.03], b: [0.5, 1.0, 0.2], cycle: 0.01 },
{ name: 'Lava', a: [0.1, 0.0, 0.0], b: [1.0, 0.3, 0.0], cycle: 0.015 },
{ name: 'Ghiaccio', a: [0.02, 0.05, 0.1], b: [0.7, 0.9, 1.0], cycle: 0.005 },
{ name: 'Viola', a: [0.08, 0.0, 0.15], b: [0.8, 0.2, 1.0], cycle: 0.02 },
{ name: 'Oro', a: [0.1, 0.06, 0.0], b: [1.0, 0.85, 0.3], cycle: 0.008 },
{ name: 'Arcobaleno', a: [0.6, 0.1, 0.8], b: [0.1, 0.9, 0.3], cycle: 0.12 },
{ name: 'Monocromo', a: [0.0, 0.0, 0.0], b: [1.0, 1.0, 1.0], cycle: 0.0, sat: 0.0 },
{ name: 'Acido', a: [0.1, 0.2, 0.0], b: [0.7, 1.0, 0.0], cycle: 0.03 },
{ name: 'Pastello', a: [0.4, 0.3, 0.5], b: [1.0, 0.8, 0.9], cycle: 0.02, sat: 0.7 },
{ name: 'Cyberpunk', a: [0.2, 0.0, 0.3], b: [1.0, 0.1, 0.6], cycle: 0.04 },
{ name: 'Infrarosso', a: [0.0, 0.0, 0.1], b: [1.0, 0.0, 0.2], cycle: 0.02 },
{ name: 'Menta', a: [0.0, 0.1, 0.08], b: [0.4, 1.0, 0.8], cycle: 0.01 },
{ name: 'Sabbia', a: [0.15, 0.1, 0.05], b: [0.95, 0.8, 0.55], cycle: 0.006 },
{ name: 'Semaforo', a: [0.0, 1.0, 0.2], b: [1.0, 0.0, 0.0], cycle: 0.0 } // green -> red (VU)
];
// Style variants: how the universal modifiers are set.
const VARIANTS = [
{ suffix: '', sym: 0, warp: 0.0, speed: 1.0, rotSpeed: 0.0, contrast: 0.8 },
{ suffix: ' · Kaleido', sym: 6, warp: 0.0, speed: 1.0, rotSpeed: 0.04, contrast: 0.9 },
{ suffix: ' · Specchio8', sym: 8, warp: 0.1, speed: 1.0, rotSpeed: 0.02, contrast: 0.9 },
{ suffix: ' · Warp', sym: 0, warp: 0.5, speed: 1.4, rotSpeed: 0.0, contrast: 0.8 },
{ suffix: ' · Turbo', sym: 0, warp: 0.2, speed: 2.2, rotSpeed: 0.06, contrast: 1.0 },
{ suffix: ' · Mirror12', sym: 12, warp: 0.0, speed: 0.8, rotSpeed: 0.03, contrast: 0.95 }
];
function makeEffect(fi, pi, vi) {
const fam = FAMILIES[fi], pal = PALETTES[pi], v = VARIANTS[vi];
return {
name: `${fam.name} · ${pal.name}${v.suffix}`,
familyName: fam.name,
paletteName: pal.name,
family: fi,
scale: fam.scale,
rot: 0,
rotSpeed: v.rotSpeed,
sym: v.sym,
hueBase: 0,
hueCycle: pal.cycle,
sat: pal.sat !== undefined ? pal.sat : 1.0,
contrast: v.contrast,
invert: 0,
warp: v.warp,
audioMix: 1.0,
speed: v.speed,
colorA: pal.a,
colorB: pal.b,
bgDark: fam.bgDark ? 1 : 0
};
}
// Build the full catalog (16 × 16 × 6 = 1536 presets).
const EFFECTS = [];
for (let fi = 0; fi < FAMILIES.length; fi++)
for (let pi = 0; pi < PALETTES.length; pi++)
for (let vi = 0; vi < VARIANTS.length; vi++)
EFFECTS.push(makeEffect(fi, pi, vi));
window.EFFECTS = {
list: EFFECTS,
families: FAMILIES.map(f => f.name),
palettes: PALETTES.map(p => p.name),
count: EFFECTS.length,
defaults: () => makeEffect(2, 0, 0) // Plasma · Neon
};
})();
+5
View File
@@ -13,6 +13,10 @@
<!-- Image overlay layer -->
<div id="overlay-images"></div>
<!-- Two independent, freely-positioned & resizable logos -->
<img id="logo-0" class="logo" />
<img id="logo-1" class="logo" />
<!-- Scrolling text ticker -->
<div id="ticker"><div id="ticker-track"><span></span><span></span></div></div>
@@ -22,6 +26,7 @@
</div>
<script src="shaders.js"></script>
<script src="effects.js"></script>
<script src="visualizer.js"></script>
<script src="audio.js"></script>
<script src="output.js"></script>
+170 -4
View File
@@ -55,6 +55,144 @@ function restartSlideshow() {
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
}
// ---------------------------------------------------------------- recording
// Records the output canvas (video) + the audio tap into a WebM stream that
// main transcodes to MP4 at the chosen aspect ratio.
let recorder = null, recording = false, pendingRecOpts = null;
let recCanvas = null, recCtx = null, composeRAF = null;
function pickMime() {
const cands = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
for (const m of cands) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
return 'video/webm';
}
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D
// canvas so the recording captures everything that's on screen, not just WebGL.
function composeFrame() {
const W = recCanvas.width, H = recCanvas.height;
const sx = W / window.innerWidth, sy = H / window.innerHeight;
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
recCtx.clearRect(0, 0, W, H);
recCtx.drawImage(canvas, 0, 0, W, H);
for (const im of images) {
if (!im.classList.contains('show')) continue;
const r = im.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(getComputedStyle(im).opacity) || 1;
const bm = im.style.mixBlendMode;
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
try { recCtx.drawImage(im, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
recCtx.globalCompositeOperation = 'source-over';
for (const l of logos) {
if (!l.classList.contains('show')) continue;
const r = l.getBoundingClientRect();
recCtx.globalAlpha = parseFloat(l.style.opacity || '1');
try { recCtx.drawImage(l, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
}
recCtx.globalAlpha = 1;
if (ticker.classList.contains('show')) {
for (const span of tickerSpans) {
const txt = span.textContent;
if (!txt) continue;
const r = span.getBoundingClientRect();
if (r.right < 0 || r.left > window.innerWidth) continue;
const cs = getComputedStyle(span);
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
recCtx.fillStyle = cs.color;
recCtx.textBaseline = 'middle';
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
recCtx.shadowBlur = 16 * sy;
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
recCtx.shadowBlur = 0;
}
}
composeRAF = requestAnimationFrame(composeFrame);
}
function stopCompose() {
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
}
async function startRecording() {
if (recording) return;
try {
await djv.recStart();
recCanvas = document.createElement('canvas');
recCanvas.width = canvas.width;
recCanvas.height = canvas.height;
recCtx = recCanvas.getContext('2d');
composeFrame();
const vstream = recCanvas.captureStream(30);
const astream = audio.recordDest.stream;
const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]);
recorder = new MediaRecorder(stream, { mimeType: pickMime(), videoBitsPerSecond: 12e6 });
// Serialise chunk delivery: ondataavailable is async, so without a chain the
// header chunk could be sent after a later one and corrupt the WebM.
let chain = Promise.resolve();
recorder.ondataavailable = (e) => {
if (!e.data || !e.data.size) return;
chain = chain.then(async () => djv.recChunk(new Uint8Array(await e.data.arrayBuffer())));
};
recorder.onstop = async () => {
stopCompose();
await chain; // ensure every chunk is flushed, in order, before muxing
const res = await djv.recStop(pendingRecOpts || {});
recording = false;
djv.report({ type: 'recState', recording: false });
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path });
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
};
recorder.start(2000); // emit a chunk every 2s
recording = true;
djv.report({ type: 'recState', recording: true });
} catch (e) {
stopCompose();
djv.report({ type: 'recError', message: e.message });
}
}
function stopRecording(opts) {
if (!recording || !recorder) return;
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
recorder.stop();
}
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
// pixels can be uploaded to a WebGL texture.
function loadCustomTexture(dataUrl) {
const img = new Image();
img.onload = () => {
const size = 1024;
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const iw = img.width || 512, ih = img.height || 512;
const s = Math.min(size / iw, size / ih);
const w = iw * s, h = ih * s;
ctx.clearRect(0, 0, size, size);
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
try { viz.setTexture(cv); hideHint(); }
catch (e) { djv.report({ type: 'error', message: 'Texture SVG: ' + e.message }); }
};
img.onerror = () => djv.report({ type: 'error', message: 'SVG/immagine non caricata' });
img.src = dataUrl;
}
// Two independent logos.
const logos = [$('#logo-0'), $('#logo-1')];
function setLogo(i, url) {
if (!logos[i]) return;
if (url) { logos[i].src = url; logos[i].classList.add('show'); hideHint(); }
else { logos[i].classList.remove('show'); logos[i].removeAttribute('src'); }
}
const ticker = $('#ticker');
const tickerTrack = $('#ticker-track');
const tickerSpans = ticker.querySelectorAll('span');
@@ -68,17 +206,27 @@ function hideHint() { $('#drop-hint').classList.add('hidden'); }
// ---------------------------------------------------------------- audio devices
async function reportDevices() {
try {
const devices = await audio.listInputDevices();
djv.report({ type: 'devices', list: devices.map(d => ({ deviceId: d.deviceId, label: d.label })) });
const inputs = await audio.listInputDevices();
djv.report({ type: 'devices', list: inputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
const outputs = await audio.listOutputDevices();
djv.report({ type: 'outputs', list: outputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
} catch (e) { /* ignore */ }
}
// ---------------------------------------------------------------- command bus
djv.onControl(async (m) => {
switch (m.type) {
case 'scene': viz.setScene(m.index); break;
case 'effect': viz.setEffect(m.effect); break;
case 'svg': loadCustomTexture(m.dataUrl); break;
case 'recStart': startRecording(); break;
case 'recStop': stopRecording(m); break;
case 'gain': audio.gain = m.value; break;
case 'speed': speed = m.value; break;
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
case 'outputDevice':
try { await audio.setOutputDevice(m.deviceId || ''); }
catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'loadFile':
try {
@@ -88,6 +236,14 @@ djv.onControl(async (m) => {
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'playTrack':
try {
audio.onEnded = () => djv.report({ type: 'trackEnded' });
await audio.loadFile(toFileURL(m.path), { loop: false });
hideHint();
djv.report({ type: 'playState', playing: true });
} catch (e) { djv.report({ type: 'error', message: e.message }); }
break;
case 'togglePlay': {
const playing = audio.togglePlay();
if (playing !== null) djv.report({ type: 'playState', playing });
@@ -109,6 +265,12 @@ djv.onControl(async (m) => {
case 'imgNext': showImage(imgIndex + 1); break;
case 'imgPrev': showImage(imgIndex - 1); break;
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
case 'logoSize': logos[m.index].style.width = m.value + 'vw'; break;
case 'logoOpacity': logos[m.index].style.opacity = m.value; break;
case 'tickerText': setTickerText(m.text); break;
case 'tickerOn': ticker.classList.toggle('show', m.on); break;
case 'tickerPos':
@@ -121,7 +283,7 @@ djv.onControl(async (m) => {
// ---------------------------------------------------------------- render loop
const startTime = performance.now();
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0;
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0;
function frame() {
const t = (performance.now() - startTime) / 1000;
@@ -129,6 +291,10 @@ function frame() {
const a = audio.values;
viz.render(t * speed, a);
// Report beat rising-edges so the control window can auto-cycle effects.
if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' });
prevBeat = a.beat;
// Overlays react to audio.
imgLayer.style.setProperty('--beat-scale',
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
+310 -259
View File
@@ -1,295 +1,346 @@
// GLSL ES 3.00 shaders for the visualizer. Stored as strings, compiled at runtime.
// Parametric "uber" shader: 16 visual families selected by uFamily, plus a set
// of universal modifiers (zoom, rotation, kaleidoscope symmetry, domain warp,
// palette, hue cycle, saturation, contrast, invert, audio mix). Combining these
// with the effect catalog (effects.js) yields hundreds of distinct looks.
const VERT = `#version 300 es
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;
// Shared fragment-shader prelude: uniforms + helpers, prepended to every scene.
const FRAG_HEADER = `#version 300 es
const FRAG = `#version 300 es
precision highp float;
out vec4 fragColor;
uniform vec2 uRes;
uniform float uTime;
uniform float uBass; // 0..1 smoothed low band
uniform float uMid; // 0..1 smoothed mid band
uniform float uTreble; // 0..1 smoothed high band
uniform float uLevel; // 0..1 overall loudness
uniform float uBeat; // 0..1 decaying pulse on detected beats
uniform float uBass, uMid, uTreble, uLevel, uBeat;
uniform int uFamily;
uniform float uScale, uRot, uRotSpeed, uSym;
uniform float uHueBase, uHueCycle, uSat, uContrast, uInvert, uWarp, uAudioMix, uSpeed;
uniform vec3 uColorA, uColorB;
uniform sampler2D uTex; // custom SVG / image source (uploaded flipped-Y)
uniform float uSpectrum[32]; // live 32-band spectrum for VU meters
uniform float uBgDark; // 1 = force the empty field (v→0) to black
float uT = 0.0; // time * effect speed (set in main)
float aMix = 1.0; // audio mix (set in main)
mat2 rot(float a) { float s = sin(a), c = cos(a); return mat2(c, -s, s, c); }
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
float a = hash(i), b = hash(i + vec2(1, 0)), c = hash(i + vec2(0, 1)), d = hash(i + vec2(1, 1));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 6; i++) { v += a * noise(p); p *= 2.02; a *= 0.5; } return v; }
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 6; i++) {
v += a * noise(p);
p *= 2.02;
a *= 0.5;
// ---- Families: each returns a scalar field in roughly [0,1] ----
float famJulia(vec2 uv) {
vec2 c = vec2(0.7885 * cos(uT * 0.15), 0.7885 * sin(uT * 0.17));
c += uTreble * 0.1 * aMix * vec2(sin(uT * 6.0), cos(uT * 5.0));
vec2 z = uv * 1.4; float it = 0.0;
for (float i = 0.0; i < 128.0; i++) { z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c; if (dot(z, z) > 16.0) break; it++; }
if (it >= 128.0) return 0.0;
return (it - log2(log2(dot(z, z))) + 4.0) / 128.0;
}
float famMandel(vec2 uv) {
vec2 c = uv * 1.5 - vec2(0.5, 0.0); vec2 z = vec2(0.0); float it = 0.0;
for (float i = 0.0; i < 128.0; i++) { z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c; if (dot(z, z) > 16.0) break; it++; }
if (it >= 128.0) return 0.0;
return (it - log2(log2(dot(z, z))) + 4.0) / 128.0;
}
float famPlasma(vec2 uv) {
float t = uT * 0.2;
vec2 q = vec2(fbm(uv * 1.5 + t), fbm(uv * 1.5 - t + 5.2));
vec2 r = vec2(fbm(uv * 2.0 + 1.7 * q + 0.15 * t), fbm(uv * 2.0 + 1.7 * q + vec2(8.3, 2.8) - 0.12 * t));
return fbm(uv * 2.0 + 3.0 * r + uBass * aMix);
}
float famVortex(vec2 uv) {
float r = length(uv), a = atan(uv.y, uv.x);
float swirl = a + (1.2 + uBass * 2.0 * aMix) / (r + 0.15) - uT * 0.6;
return (sin(swirl * 5.0 + r * 12.0 - uT * 2.0) * 0.5 + 0.5) * (0.4 + 0.8 * fbm(uv * 3.0));
}
float famWaves(vec2 uv) {
float r = length(uv);
float energy = mix(uBass, uTreble, clamp(r, 0.0, 1.0)) * aMix + 0.3;
return (1.0 - smoothstep(0.0, 0.6, abs(sin(r * 24.0 - uT * 4.0)))) * energy;
}
float famCells(vec2 uv) {
vec2 p = uv * 4.0 + 8.0; vec2 ip = floor(p), fp = fract(p);
float d1 = 8.0, d2 = 8.0;
for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 o = vec2(hash(ip + g), hash(ip + g + 3.7));
float d = length(g + 0.5 + 0.45 * sin(uT * 0.6 + o * 6.283) - fp);
if (d < d1) { d2 = d1; d1 = d; } else if (d < d2) d2 = d;
}
return clamp(d2 - d1, 0.0, 1.0);
}
float famHyper(vec2 uv) {
float v = 0.0, warp = 0.4 + uBass * 2.0 * aMix;
for (int i = 0; i < 40; i++) {
float fi = float(i);
float ang = hash(vec2(fi, 1.0)) * 6.283;
float z = fract(hash(vec2(fi, 3.0)) + uT * (0.25 + hash(vec2(fi, 2.0)) * 0.9) * warp);
vec2 pos = vec2(cos(ang), sin(ang)) * z * z * 1.6;
v += smoothstep(0.06 * z + 0.004, 0.0, length(uv - pos)) * z;
}
return clamp(v, 0.0, 1.0);
}
float famTunnel(vec2 uv) {
float a = atan(uv.y, uv.x), r = length(uv);
float depth = 0.35 / (r + 0.05) + uT * (0.3 + uBass * 1.2 * aMix);
float pattern = fbm(vec2(a * 2.0, depth) * 3.0) + 0.5 * sin(a * 8.0 + uT);
return clamp(pattern * (sin(depth * 10.0 - uT * 2.0) * 0.5 + 0.5) * smoothstep(0.0, 0.5, r), 0.0, 1.0);
}
float famMoire(vec2 uv) {
float r = length(uv);
return (sin(r * 40.0 - uT * 2.0) * sin(dot(uv, uv) * 30.0 + uT) * sin(atan(uv.y, uv.x) * 20.0 + uT)) * 0.5 + 0.5;
}
float famTruchet(vec2 uv) {
vec2 p = uv * 3.0; vec2 fp = fract(p) - 0.5;
if (hash(floor(p)) < 0.5) fp.x = -fp.x;
float dd = min(abs(length(fp - 0.5) - 0.5), abs(length(fp + 0.5) - 0.5));
return smoothstep(0.08, 0.0, dd - 0.02 * sin(uT * 2.0));
}
float famGyroid(vec2 uv) {
vec2 p = uv * 4.0;
return (sin(p.x + uT) * cos(p.y) + sin(p.y + uT * 0.7) * cos(p.x * 1.3)) * 0.25 + 0.5;
}
float famHex(vec2 uv) {
vec2 p = uv * 3.0; vec2 h = vec2(1.0, 1.732);
vec2 a = mod(p, h) - h * 0.5, b = mod(p - h * 0.5, h) - h * 0.5;
vec2 gv = dot(a, a) < dot(b, b) ? a : b;
return smoothstep(0.5, 0.45, length(gv) + 0.05 * sin(uT * 2.0));
}
float famGrid(vec2 uv) {
vec2 g = sin(uv * 8.0 + vec2(uT, uT * 1.3));
return g.x * g.y * 0.5 + 0.5;
}
float famClouds(vec2 uv) { return fbm(uv * 2.5 + vec2(uT * 0.1, uT * 0.07)); }
float famSpiral(vec2 uv) {
float r = length(uv), a = atan(uv.y, uv.x);
return sin(6.0 * a + log(r + 0.001) * 6.0 - uT * 2.0) * 0.5 + 0.5;
}
float famCrystals(vec2 uv) {
vec2 p = fract(uv * 2.0) - 0.5;
float a = atan(p.y, p.x), r = length(p);
float star = cos(a * 5.0 + uT) * 0.2 + 0.3;
return smoothstep(star, star - 0.05, r);
}
// ---- Silhouette families (people & objects via signed distance fields) ----
float sdSeg(vec2 p, vec2 a, vec2 b, float r) {
vec2 pa = p - a, ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h) - r;
}
// A dancing humanoid: torso, head, swinging arms and stepping legs. t drives
// the dance; e (audio energy) raises the arms and adds a vertical bounce.
float sdDancer(vec2 p, float t, float e) {
float b = 0.06 * e * sin(t * 6.0); // bounce on the beat
vec2 hip = vec2(0.0, -0.05 + b);
vec2 neck = vec2(0.0, 0.30 + b);
vec2 head = vec2(0.0, 0.46 + b);
float s = sin(t * 3.0);
float raise = e * 0.55;
float d = sdSeg(p, hip, neck, 0.06); // torso
d = min(d, length(p - head) - 0.10); // head
vec2 shL = neck + vec2(-0.10, 0.0), shR = neck + vec2(0.10, 0.0);
vec2 elbL = shL + vec2(-0.12, -0.10 + 0.18 * s + raise);
vec2 hndL = elbL + vec2(-0.10, 0.02 + 0.20 * s + raise);
vec2 elbR = shR + vec2( 0.12, -0.10 - 0.18 * s + raise);
vec2 hndR = elbR + vec2( 0.10, 0.02 - 0.20 * s + raise);
d = min(d, sdSeg(p, shL, elbL, 0.045));
d = min(d, sdSeg(p, elbL, hndL, 0.038));
d = min(d, sdSeg(p, shR, elbR, 0.045));
d = min(d, sdSeg(p, elbR, hndR, 0.038));
vec2 kneeL = hip + vec2(-0.06, -0.22 + 0.06 * s);
vec2 footL = kneeL + vec2(-0.02 - 0.06 * s, -0.22);
vec2 kneeR = hip + vec2( 0.06, -0.22 - 0.06 * s);
vec2 footR = kneeR + vec2( 0.02 + 0.06 * s, -0.22);
d = min(d, sdSeg(p, hip, kneeL, 0.05));
d = min(d, sdSeg(p, kneeL, footL, 0.04));
d = min(d, sdSeg(p, hip, kneeR, 0.05));
d = min(d, sdSeg(p, kneeR, footR, 0.04));
return d;
}
float famDancers(vec2 uv) {
float d = 1e9;
float energy = uBass * aMix + 0.3;
for (int i = 0; i < 5; i++) {
float fi = float(i);
vec2 p = (uv - vec2(-0.9 + fi * 0.45, -0.32)) / 0.55;
d = min(d, sdDancer(p, uT + fi * 1.3, energy));
}
return smoothstep(0.02, 0.0, d);
}
float famDancerSolo(vec2 uv) {
vec2 p = (uv - vec2(0.0, -0.15)) / 0.95;
return smoothstep(0.018, 0.0, sdDancer(p, uT * 1.2, uBass * aMix * 1.2 + 0.4));
}
float sdNote(vec2 p) {
float d = length(p * vec2(1.15, 1.0)) - 0.12; // note head
d = min(d, sdSeg(p, vec2(0.10, 0.0), vec2(0.10, 0.5), 0.022)); // stem
d = min(d, sdSeg(p, vec2(0.10, 0.5), vec2(0.24, 0.40), 0.022)); // flag
return d;
}
float famSilhouettes(vec2 uv) {
float d = 1e9;
for (int i = 0; i < 6; i++) {
float fi = float(i);
float y = 0.32 * sin(uT + fi * 1.7) + 0.06 * uBeat * aMix;
vec2 p = (uv - vec2(-0.8 + fi * 0.32, y)) / (0.5 + 0.15 * sin(fi));
d = min(d, sdNote(p));
}
return smoothstep(0.02, 0.0, d);
}
// Custom source: sample the uploaded SVG/image as the scalar field. Coverage
// (alpha) defines the shape; brightness adds inner detail.
float famCustom(vec2 uv) {
// Fit the square texture to ~70% of screen height (coeff > 1 shrinks it).
vec2 tc = uv * 1.45 + 0.5;
if (tc.x < 0.0 || tc.x > 1.0 || tc.y < 0.0 || tc.y > 1.0) return 0.0;
vec4 t = texture(uTex, tc);
float luma = dot(t.rgb, vec3(0.299, 0.587, 0.114));
return t.a * (0.55 + 0.45 * luma); // coverage dominates so dark silhouettes stay visible
}
// ---- VU-meter families (use the live spectrum) ----
float specAt(int i) { return uSpectrum[i]; }
// Classic spectrum-analyser bars rising from the bottom.
float famVUBars(vec2 uv) {
float xn = uv.x * 0.62 + 0.5; // map width to [0,1]
if (xn < 0.0 || xn > 1.0) return 0.0;
float N = 32.0;
int col = int(clamp(xn * N, 0.0, N - 1.0));
float level = specAt(col);
float yb = (uv.y + 0.46) / 0.92; // 0 bottom -> 1 top
if (yb < 0.0 || yb > 1.0) return 0.0;
float bx = fract(xn * N);
float gap = smoothstep(0.04, 0.12, bx) * smoothstep(0.96, 0.88, bx); // bar spacing
float lit = step(yb, level) * gap;
// peak cap line just above the level
float cap = smoothstep(0.03, 0.0, abs(yb - level)) * gap;
return max(lit * (0.2 + 0.8 * yb), cap);
}
// Analogue needle gauge.
float famVUNeedle(vec2 uv) {
vec2 p = uv - vec2(0.0, -0.28);
float r = length(p);
float ang = atan(p.x, p.y); // 0 = straight up
float na = (clamp(uLevel, 0.0, 1.0) - 0.5) * 1.7 + uBass * 0.15;
float needle = smoothstep(0.045, 0.0, abs(ang - na)) * step(r, 0.62) * step(0.04, r);
float arc = smoothstep(0.018, 0.0, abs(r - 0.62)) * step(abs(ang), 0.9);
float hub = smoothstep(0.06, 0.045, r);
// red zone near the top of the scale
float redzone = step(0.55, ang) * arc;
return max(max(needle, hub), arc * (0.5 + 0.5 * redzone));
}
// Stereo LED level meters (two segmented horizontal bars).
float famVUStereo(vec2 uv) {
float xn = uv.x * 0.6 + 0.5;
if (xn < 0.0 || xn > 1.0) return 0.0;
float lvlTop = clamp(uLevel * 1.1, 0.0, 1.0);
float lvlBot = clamp((uBass + uTreble) * 0.6, 0.0, 1.0);
float seg = step(0.18, fract(xn * 26.0)); // LED gaps
float v = 0.0;
if (abs(uv.y - 0.12) < 0.07) v = step(xn, lvlTop) * seg * (0.25 + 0.75 * xn);
if (abs(uv.y + 0.12) < 0.07) v = step(xn, lvlBot) * seg * (0.25 + 0.75 * xn);
return v;
}
`;
// ---- Scene 0: Julia / Mandelbrot-style fractal -------------------------
const FRAG_FRACTAL = FRAG_HEADER + `
float field(int f, vec2 uv) {
if (f == 0) return famJulia(uv);
if (f == 1) return famMandel(uv);
if (f == 2) return famPlasma(uv);
if (f == 3) return famVortex(uv);
if (f == 4) return famWaves(uv);
if (f == 5) return famCells(uv);
if (f == 6) return famHyper(uv);
if (f == 7) return famTunnel(uv);
if (f == 8) return famMoire(uv);
if (f == 9) return famTruchet(uv);
if (f == 10) return famGyroid(uv);
if (f == 11) return famHex(uv);
if (f == 12) return famGrid(uv);
if (f == 13) return famClouds(uv);
if (f == 14) return famSpiral(uv);
if (f == 16) return famDancers(uv);
if (f == 17) return famDancerSolo(uv);
if (f == 18) return famSilhouettes(uv);
if (f == 19) return famCustom(uv);
if (f == 20) return famVUBars(uv);
if (f == 21) return famVUNeedle(uv);
if (f == 22) return famVUStereo(uv);
return famCrystals(uv); // f == 15
}
vec3 colorize(float v, vec2 uv0) {
v = clamp(v, 0.0, 1.0);
vec3 hsv = rgb2hsv(mix(uColorA, uColorB, v));
hsv.x = fract(hsv.x + uHueBase + uHueCycle * uT + uTreble * 0.06 * aMix);
hsv.y = clamp(hsv.y * uSat, 0.0, 1.0);
hsv.z = pow(clamp(hsv.z, 0.0, 1.0), uContrast) * (0.7 + 0.6 * uLevel * aMix);
vec3 col = hsv2rgb(hsv);
if (uInvert > 0.5) col = vec3(1.0) - col;
// For silhouette/meter effects, fade the empty field to black.
col *= mix(1.0, smoothstep(0.0, 0.04, v), uBgDark);
col += uBeat * 0.18 * aMix;
col *= 1.0 - 0.28 * dot(uv0, uv0);
return col;
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
uT = uTime * uSpeed;
aMix = uAudioMix;
vec2 uv0 = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
vec2 uv = rot(uRot + uRotSpeed * uT) * uv0;
uv /= (uScale * (1.0 + uBass * 0.4 * aMix));
// Bass-driven zoom breathing + slow drift.
float zoom = 1.3 + 0.55 * sin(uTime * 0.07) + uBass * 0.9;
uv /= zoom;
// Slowly wandering Julia constant; treble adds jitter on the path.
float t = uTime * 0.15;
vec2 c = vec2(0.7885 * cos(t), 0.7885 * sin(t * 1.13));
c += (uTreble * 0.12) * vec2(sin(uTime * 6.0), cos(uTime * 5.0));
vec2 z = uv * 1.4;
float iter = 0.0;
const float MAX = 160.0;
for (float i = 0.0; i < MAX; i++) {
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
if (dot(z, z) > 16.0) break;
iter++;
if (uSym > 0.5) {
float a = atan(uv.y, uv.x), r = length(uv);
a = mod(a, 6.2831853 / uSym);
a = abs(a - 3.14159265 / uSym);
uv = vec2(cos(a), sin(a)) * r;
}
if (uWarp > 0.001) {
uv += uWarp * vec2(fbm(uv * 2.0 + uT * 0.1), fbm(uv * 2.0 - uT * 0.1 + 3.3));
}
// Smooth iteration count for banding-free color.
float sm = iter - log2(log2(dot(z, z))) + 4.0;
float m = sm / MAX;
vec3 col;
if (iter >= MAX) {
col = vec3(0.0);
} else {
float hue = fract(0.55 + m * 2.5 + uTime * 0.03 + uMid * 0.3);
float sat = 0.65 + 0.35 * uTreble;
float val = pow(m, 0.45) * (0.7 + 0.6 * uLevel);
col = hsv2rgb(vec3(hue, sat, clamp(val, 0.0, 1.2)));
}
// Beat flash + soft vignette.
col += uBeat * 0.25 * vec3(0.6, 0.7, 1.0);
float vig = 1.0 - 0.35 * dot(uv * zoom, uv * zoom);
col *= clamp(vig, 0.0, 1.0);
fragColor = vec4(col, 1.0);
float v = field(uFamily, uv);
fragColor = vec4(colorize(v, uv0), 1.0);
}
`;
// ---- Scene 1: Plasma / domain-warped flow ------------------------------
const FRAG_PLASMA = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float t = uTime * (0.15 + uMid * 0.4);
// Domain warping: feed fbm into fbm for liquid motion.
vec2 q = vec2(fbm(uv * 1.5 + t), fbm(uv * 1.5 - t + 5.2));
vec2 r = vec2(
fbm(uv * 2.0 + 1.7 * q + vec2(1.7, 9.2) + 0.15 * t),
fbm(uv * 2.0 + 1.7 * q + vec2(8.3, 2.8) - 0.12 * t)
);
float f = fbm(uv * 2.0 + 3.0 * r + uBass * 1.2);
float hue = fract(0.6 + f * 0.6 + length(r) * 0.3 + uTime * 0.02);
float val = 0.25 + 1.1 * f * (0.5 + uLevel);
vec3 col = hsv2rgb(vec3(hue, 0.7 + 0.3 * uTreble, val));
// Bright filaments where the warp field folds.
col += vec3(0.9, 0.95, 1.0) * pow(length(r), 3.0) * (0.4 + uBeat);
col *= 0.85 + 0.5 * uBass;
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 2: Kaleidoscope tunnel --------------------------------------
const FRAG_TUNNEL = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float a = atan(uv.y, uv.x);
float rad = length(uv);
// Kaleidoscope: fold angle into N reactive segments.
float seg = 6.0 + floor(uMid * 8.0);
a = abs(mod(a, 6.28318 / seg) - 3.14159 / seg);
// Tunnel: travel inward, speed pulses with bass.
float depth = 0.35 / (rad + 0.05) + uTime * (0.3 + uBass * 1.4);
vec2 tuv = vec2(a * 2.0, depth);
float pattern = fbm(tuv * 3.0) + 0.5 * sin(tuv.x * 8.0 + uTime);
float rings = sin(depth * 10.0 - uTime * 2.0) * 0.5 + 0.5;
float hue = fract(0.5 + depth * 0.05 + pattern * 0.3 + uTreble * 0.4);
float val = (0.2 + 0.9 * pattern) * rings * (0.6 + uLevel);
// Fade core to black so the tunnel reads as depth.
val *= smoothstep(0.0, 0.5, rad);
vec3 col = hsv2rgb(vec3(hue, 0.8, val));
col += uBeat * 0.4 * vec3(1.0, 0.5, 0.9) * (1.0 - rad);
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 3: Vortice (swirl) ------------------------------------------
const FRAG_VORTEX = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float r = length(uv);
float a = atan(uv.y, uv.x);
// Spiral arms that twist harder near the centre and spin with the bass.
float swirl = a + (1.2 + uBass * 3.0) / (r + 0.15) - uTime * (0.6 + uMid);
float arms = sin(swirl * 5.0 + r * 12.0 - uTime * 2.0) * 0.5 + 0.5;
float n = fbm(uv * 3.0 + vec2(cos(uTime * 0.2), sin(uTime * 0.2)));
float v = arms * (0.4 + 0.8 * n);
float hue = fract(0.72 + r * 0.35 + uTime * 0.04 + uTreble * 0.3);
vec3 col = hsv2rgb(vec3(hue, 0.85, v * (0.5 + uLevel)));
col += uBeat * 0.3 * vec3(1.0, 0.6, 0.9) * (1.0 - r);
col *= smoothstep(0.0, 0.15, r); // dark eye in the middle
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 4: Onde (reactive frequency rings) --------------------------
const FRAG_WAVES = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float r = length(uv);
float a = atan(uv.y, uv.x);
// Energy mapped radially: bass at the core, treble at the edges.
float energy = mix(uBass, mix(uMid, uTreble, smoothstep(0.4, 1.0, r)), smoothstep(0.0, 0.5, r));
// Outgoing rings whose spacing breathes with the beat.
float rings = sin(r * (24.0 + uBeat * 20.0) - uTime * 4.0);
rings = smoothstep(0.0, 0.6, abs(rings));
rings = 1.0 - rings;
// Angular petals add structure to the rings.
float petals = 0.5 + 0.5 * sin(a * (8.0 + floor(uMid * 10.0)) + uTime);
float glow = rings * (0.3 + 1.4 * energy) * (0.5 + 0.7 * petals);
float hue = fract(0.55 + r * 0.5 - uTime * 0.05 + energy * 0.4);
vec3 col = hsv2rgb(vec3(hue, 0.8, glow));
col += energy * uBeat * vec3(0.6, 0.8, 1.0) * (1.0 - r);
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 5: Cellule (animated voronoi) -------------------------------
const FRAG_CELLS = FRAG_HEADER + `
vec2 cellPoint(vec2 ip, float t) {
// A wandering feature point inside cell ip.
vec2 o = vec2(hash(ip), hash(ip + 3.7));
return 0.5 + 0.45 * sin(t * (0.4 + o * 0.8) + o * 6.283);
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
float scale = 4.0 - uBass * 1.5; // cells swell on the bass
vec2 p = uv * scale + 8.0;
vec2 ip = floor(p), fp = fract(p);
float d1 = 8.0, d2 = 8.0; // nearest + second-nearest
vec2 nearId = vec2(0.0);
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 g = vec2(float(x), float(y));
vec2 pt = g + cellPoint(ip + g, uTime * (0.6 + uMid));
float d = length(pt - fp);
if (d < d1) { d2 = d1; d1 = d; nearId = ip + g; }
else if (d < d2) { d2 = d; }
}
}
float edge = smoothstep(0.0, 0.08 + uTreble * 0.05, d2 - d1); // bright borders
float cellHue = fract(hash(nearId) + uTime * 0.03 + uMid * 0.2);
vec3 base = hsv2rgb(vec3(cellHue, 0.7, 0.25 + 0.6 * (1.0 - d1)));
vec3 col = base * (0.4 + 0.6 * edge);
col += (1.0 - edge) * uBeat * 0.5 * vec3(1.0, 0.9, 0.7);
col *= 0.7 + 0.6 * uLevel;
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 6: Iperspazio (starfield warp) ------------------------------
const FRAG_HYPER = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
vec3 col = vec3(0.0);
float warp = 0.4 + uBass * 2.2; // travel speed pulses with the bass
for (int i = 0; i < 48; i++) {
float fi = float(i);
float ang = hash(vec2(fi, 1.0)) * 6.2831853;
float spd = 0.25 + hash(vec2(fi, 2.0)) * 0.9;
float z = fract(hash(vec2(fi, 3.0)) + uTime * spd * warp);
float rad = z * z * 1.6; // accelerate outward
vec2 pos = vec2(cos(ang), sin(ang)) * rad;
float d = length(uv - pos);
// Stretch the star into a streak along its travel direction.
float streak = smoothstep(0.06 * z + 0.004, 0.0, d) * z;
float hue = fract(0.6 + hash(vec2(fi, 4.0)) * 0.3 + uTreble * 0.3);
col += hsv2rgb(vec3(hue, 0.5 + 0.5 * uMid, 1.0)) * streak;
}
col *= 0.7 + 0.8 * uLevel;
col += uBeat * 0.15;
fragColor = vec4(col, 1.0);
}
`;
// ---- Scene 7: Specchi (kaleidoscope plasma) ----------------------------
const FRAG_MIRRORS = FRAG_HEADER + `
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
// Fold space into a reactive number of mirrored wedges.
float seg = 4.0 + 2.0 * floor(1.0 + uMid * 5.0);
float a = atan(uv.y, uv.x);
float r = length(uv);
a = mod(a, 6.2831853 / seg);
a = abs(a - 3.1415926 / seg);
vec2 muv = vec2(cos(a), sin(a)) * r;
// Plasma inside the wedge, scrolling with the beat.
float t = uTime * (0.2 + uBass * 0.5);
vec2 q = vec2(fbm(muv * 2.5 + t), fbm(muv * 2.5 - t + 4.0));
float f = fbm(muv * 3.0 + 2.0 * q + uBeat);
float hue = fract(0.5 + f * 0.6 + r * 0.4 + uTime * 0.03);
vec3 col = hsv2rgb(vec3(hue, 0.8 + 0.2 * uTreble, 0.2 + 1.1 * f * (0.5 + uLevel)));
col += pow(length(q), 2.5) * vec3(0.9, 0.95, 1.0) * (0.4 + uBeat);
fragColor = vec4(col, 1.0);
}
`;
window.SHADERS = {
vert: VERT,
names: ['Frattale', 'Plasma', 'Tunnel', 'Vortice', 'Onde', 'Cellule', 'Iperspazio', 'Specchi'],
scenes: [FRAG_FRACTAL, FRAG_PLASMA, FRAG_TUNNEL, FRAG_VORTEX, FRAG_WAVES, FRAG_CELLS, FRAG_HYPER, FRAG_MIRRORS]
};
window.SHADERS = { vert: VERT, frag: FRAG };
+218 -7
View File
@@ -45,27 +45,54 @@ body.control {
#topbar strong { font-size: 15px; letter-spacing: 0.3px; }
#topbar #status { font-size: 11px; opacity: 0.6; font-variant-numeric: tabular-nums; }
#panel-grid {
flex: 1 1 auto;
/* Tab bar */
#tabs {
flex: 0 0 auto;
display: flex;
gap: 4px;
padding: 8px 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(20,20,30,0.45);
overflow-x: auto;
}
#tabs button {
flex: 0 0 auto;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 8px 8px 0 0;
padding: 8px 12px;
opacity: 0.6;
font-size: 13px;
}
#tabs button:hover { background: rgba(255,255,255,0.06); opacity: 0.9; }
#tabs button.active {
opacity: 1;
background: rgba(110,150,255,0.14);
border-bottom-color: #7aa2ff;
}
#content { flex: 1 1 auto; overflow-y: auto; min-width: 0; }
.tab-panel { display: none; }
.tab-panel.active {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 14px;
align-content: start;
overflow-y: auto;
min-width: 0;
}
#panel-grid section {
.tab-panel section {
min-width: 0; /* allow grid cells to shrink, no overflow */
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 14px;
padding: 12px;
}
#panel-grid section.span2 { grid-column: 1 / -1; }
.tab-panel section.span2 { grid-column: 1 / -1; }
@media (max-width: 460px) {
#panel-grid { grid-template-columns: 1fr; }
.tab-panel.active { grid-template-columns: 1fr; }
}
body.control label {
@@ -91,6 +118,176 @@ body.control .chk { text-transform: none; letter-spacing: 0; opacity: 0.85; marg
body.control select { width: 100%; max-width: 100%; }
/* Per-band equaliser labels + sliders */
.band { display: block; }
#bass-gain { accent-color: #5ad1ff; }
#mid-gain { accent-color: #7aa2ff; }
#treble-gain { accent-color: #ff7ad1; }
.band-bass span, .band-mid span, .band-treble span { color: #fff; }
/* ---- Effect library ---- */
.effect-list {
margin-top: 8px;
max-height: 220px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 4px;
}
.fx {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 7px;
font-size: 12px;
cursor: pointer;
}
.fx:hover { background: rgba(255,255,255,0.07); }
.fx.active { background: rgba(110,150,255,0.25); }
.fx .fx-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fx .fx-add {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
}
#effect-family { flex: 0 0 45%; }
#effect-search { flex: 1; margin: 0; }
/* ---- Pad bank (5×5 launchpad) ---- */
.pad-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
margin-top: 8px;
}
.pad {
position: relative;
aspect-ratio: 1 / 1;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
background: rgba(255,255,255,0.04);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
overflow: hidden;
padding: 4px;
transition: background 0.12s ease, box-shadow 0.12s ease;
}
.pad:hover { background: rgba(255,255,255,0.09); }
.pad.filled { background: rgba(90,120,200,0.18); border-color: rgba(120,150,255,0.4); }
.pad.active {
background: rgba(60,200,120,0.35);
border-color: #5ad18a;
box-shadow: 0 0 12px rgba(90,230,150,0.55);
}
.pad-num { position: absolute; top: 3px; left: 5px; font-size: 9px; opacity: 0.5; }
.pad-name {
font-size: 9.5px;
line-height: 1.15;
word-break: break-word;
max-height: 100%;
overflow: hidden;
opacity: 0.92;
}
.pad-plus { font-size: 22px; opacity: 0.3; }
.pad-x {
position: absolute;
top: 1px; right: 2px;
background: transparent;
border: none;
color: #ff8a8a;
font-size: 11px;
padding: 0 3px;
opacity: 0;
flex: none;
width: auto;
}
.pad:hover .pad-x { opacity: 0.9; }
/* ---- Recording status ---- */
.rec-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
}
#rec-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; }
#rec-dot.off { background: #555; }
#rec-dot.on { background: #ff3b30; box-shadow: 0 0 10px #ff3b30; animation: recpulse 1s infinite; }
#rec-time { margin-left: auto; font-variant-numeric: tabular-nums; opacity: 0.85; }
@keyframes recpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
/* ---- SVG quick-pick chips ---- */
.svg-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.svg-chip {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
padding: 4px 8px;
}
.svg-chip img {
width: 26px; height: 26px;
object-fit: contain;
background: #9aa3b2;
border-radius: 5px;
padding: 2px;
}
.svg-chip span {
font-size: 11px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Playlist ---- */
.playlist { list-style: none; margin: 8px 0 0; padding: 0; }
.hint-mini { font-size: 10px; opacity: 0.45; margin-top: 6px; }
.track {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-bottom: 5px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 9px;
font-size: 12px;
}
.track.playing {
background: rgba(110,150,255,0.22);
border-color: rgba(120,170,255,0.6);
}
.track.dragging { opacity: 0.4; }
.track .grip { cursor: grab; opacity: 0.4; font-size: 14px; }
.track .tname {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track button {
flex: 0 0 auto;
padding: 3px 8px;
border-radius: 7px;
font-size: 11px;
}
.track .key-btn {
min-width: 30px;
font-weight: 700;
background: rgba(255,255,255,0.1);
}
.track .play-btn { color: #8ef0b0; }
.track .del-btn { color: #ff8a8a; }
/* ============ OUTPUT WINDOW ============ */
body.output { cursor: none; background: #000; }
@@ -118,6 +315,20 @@ body.output { cursor: none; background: #000; }
}
#overlay-images img.show { opacity: 1; }
/* ---- Independent logos ---- */
.logo {
position: fixed;
left: 50%;
top: 50%;
width: 20vw;
transform: translate(-50%, -50%);
z-index: 5;
pointer-events: none;
display: none;
filter: drop-shadow(0 0 18px rgba(0,0,0,0.45));
}
.logo.show { display: block; }
/* ---- Scrolling text ticker ---- */
#ticker {
position: fixed;
+84 -48
View File
@@ -1,50 +1,61 @@
// WebGL2 engine: renders a fullscreen quad through one of the scene shaders.
// WebGL2 engine: renders a fullscreen quad through the parametric uber-shader,
// driven by the current effect's parameters + live audio.
class Visualizer {
constructor(canvas) {
this.canvas = canvas;
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, powerPreference: 'high-performance' });
// preserveDrawingBuffer keeps the rendered frame readable so canvas
// captureStream() (used by the MP4 recorder) captures real frames.
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
if (!gl) throw new Error('WebGL2 non disponibile su questo sistema.');
this.gl = gl;
this.programs = window.SHADERS.scenes.map(src => this._buildProgram(window.SHADERS.vert, src));
this.sceneIndex = 0;
this.program = this._buildProgram(window.SHADERS.vert, window.SHADERS.frag);
// Fullscreen triangle covering the viewport.
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
this.vbo = buf;
// Per-program attribute/uniform locations.
this.locs = this.programs.map(p => {
gl.useProgram(p);
const aPos = gl.getAttribLocation(p, 'aPos');
return {
aPos,
uRes: gl.getUniformLocation(p, 'uRes'),
uTime: gl.getUniformLocation(p, 'uTime'),
uBass: gl.getUniformLocation(p, 'uBass'),
uMid: gl.getUniformLocation(p, 'uMid'),
uTreble: gl.getUniformLocation(p, 'uTreble'),
uLevel: gl.getUniformLocation(p, 'uLevel'),
uBeat: gl.getUniformLocation(p, 'uBeat')
};
});
gl.useProgram(this.program);
const U = (n) => gl.getUniformLocation(this.program, n);
this.aPos = gl.getAttribLocation(this.program, 'aPos');
this.u = {
uRes: U('uRes'), uTime: U('uTime'),
uBass: U('uBass'), uMid: U('uMid'), uTreble: U('uTreble'), uLevel: U('uLevel'), uBeat: U('uBeat'),
uFamily: U('uFamily'), uScale: U('uScale'), uRot: U('uRot'), uRotSpeed: U('uRotSpeed'), uSym: U('uSym'),
uHueBase: U('uHueBase'), uHueCycle: U('uHueCycle'), uSat: U('uSat'), uContrast: U('uContrast'),
uInvert: U('uInvert'), uWarp: U('uWarp'), uAudioMix: U('uAudioMix'), uSpeed: U('uSpeed'),
uColorA: U('uColorA'), uColorB: U('uColorB'), uTex: U('uTex'), uSpectrum: U('uSpectrum'), uBgDark: U('uBgDark')
};
// Custom-source texture (for the SVG/Image effect family). Starts empty.
this.tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0]));
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);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
this.effect = (window.EFFECTS && window.EFFECTS.defaults()) || this._fallbackEffect();
this.resize();
window.addEventListener('resize', () => this.resize());
}
_fallbackEffect() {
return { family: 2, scale: 1.0, rot: 0, rotSpeed: 0, sym: 0, hueBase: 0, hueCycle: 0.02,
sat: 1, contrast: 0.8, invert: 0, warp: 0, audioMix: 1, speed: 1, colorA: [0.1, 0.2, 0.8], colorB: [1, 0.4, 0.9] };
}
_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)) {
const log = gl.getShaderInfoLog(sh);
throw new Error('Errore compilazione shader:\n' + log + '\n\n' + src);
}
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
throw new Error('Errore compilazione shader:\n' + gl.getShaderInfoLog(sh));
return sh;
}
@@ -54,45 +65,70 @@ class Visualizer {
gl.attachShader(p, this._compile(gl.VERTEX_SHADER, vsrc));
gl.attachShader(p, this._compile(gl.FRAGMENT_SHADER, fsrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
throw new Error('Errore link programma: ' + gl.getProgramInfoLog(p));
}
return p;
}
resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.floor(window.innerWidth * dpr);
const h = Math.floor(window.innerHeight * dpr);
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
const w = Math.floor(window.innerWidth * dpr), h = Math.floor(window.innerHeight * dpr);
if (this.canvas.width !== w || this.canvas.height !== h) { this.canvas.width = w; this.canvas.height = h; }
this.gl.viewport(0, 0, w, h);
}
setScene(i) {
this.sceneIndex = Math.max(0, Math.min(this.programs.length - 1, i));
setEffect(effect) {
// Merge onto current so partial updates (e.g. only family) are safe.
this.effect = Object.assign({}, this.effect, effect);
}
// audio: { bass, mid, treble, level, beat }
render(timeSec, audio) {
// Upload a custom source (HTMLImageElement / HTMLCanvasElement) for the
// SVG/Image effect family. Flipped on the Y axis to match screen space.
setTexture(source) {
const gl = this.gl;
const p = this.programs[this.sceneIndex];
const l = this.locs[this.sceneIndex];
gl.useProgram(p);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
}
render(timeSec, audio) {
const gl = this.gl, u = this.u, e = this.effect;
gl.useProgram(this.program);
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
gl.enableVertexAttribArray(l.aPos);
gl.vertexAttribPointer(l.aPos, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(l.uRes, this.canvas.width, this.canvas.height);
gl.uniform1f(l.uTime, timeSec);
gl.uniform1f(l.uBass, audio.bass);
gl.uniform1f(l.uMid, audio.mid);
gl.uniform1f(l.uTreble, audio.treble);
gl.uniform1f(l.uLevel, audio.level);
gl.uniform1f(l.uBeat, audio.beat);
gl.uniform2f(u.uRes, this.canvas.width, this.canvas.height);
gl.uniform1f(u.uTime, timeSec);
gl.uniform1f(u.uBass, audio.bass);
gl.uniform1f(u.uMid, audio.mid);
gl.uniform1f(u.uTreble, audio.treble);
gl.uniform1f(u.uLevel, audio.level);
gl.uniform1f(u.uBeat, audio.beat);
gl.uniform1i(u.uFamily, e.family | 0);
gl.uniform1f(u.uScale, e.scale);
gl.uniform1f(u.uRot, e.rot);
gl.uniform1f(u.uRotSpeed, e.rotSpeed);
gl.uniform1f(u.uSym, e.sym);
gl.uniform1f(u.uHueBase, e.hueBase);
gl.uniform1f(u.uHueCycle, e.hueCycle);
gl.uniform1f(u.uSat, e.sat);
gl.uniform1f(u.uContrast, e.contrast);
gl.uniform1f(u.uInvert, e.invert);
gl.uniform1f(u.uWarp, e.warp);
gl.uniform1f(u.uAudioMix, e.audioMix);
gl.uniform1f(u.uSpeed, e.speed);
gl.uniform3fv(u.uColorA, e.colorA);
gl.uniform3fv(u.uColorB, e.colorB);
gl.uniform1f(u.uBgDark, e.bgDark || 0);
if (u.uSpectrum && audio.spectrum) gl.uniform1fv(u.uSpectrum, audio.spectrum);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.uniform1i(u.uTex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}