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:
1 parent
b32ea47ae5
commit
47728d0d2a
76 files changed
+21794
-459
No files matched your search
+66
-10
@@ -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
@@ -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
@@ -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, '"') + '">' +
|
||||
(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, '"') + '">' + 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, '&').replace(/</g, '<') + '</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' });
|
||||
Vendored
+104
@@ -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
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user