DJ Visualizer Pi: standalone Node+WebSocket server with mobile PWA remote
New separate project (the Electron Mac app stays untouched). Reuses the visual engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron IPC with a Node HTTP+WebSocket server: - server.js: serves the kiosk output page + mobile control PWA, relays the same ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload, JSON state (pads), waveform peaks and MP4 recording (ffmpeg). - public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact. - public/index.html + control.js: touch-first mobile remote (effects, media/queue, ticker, pads, audio/EQ, fullscreen + recording). - PWA (manifest + service worker + generated icons). - scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5). Verified: REST endpoints, WebSocket relay both directions, control PWA loads the 2958-effect catalog and renders. (Headless WebGL output not testable without GPU.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
b94c787231
81 files changed
+23994
No files matched your search
@@ -0,0 +1,326 @@
|
||||
// Audio engine: builds a Web Audio graph from either a loaded file or a live
|
||||
// input device, runs an FFT analyser, and exposes smoothed bass/mid/treble/
|
||||
// level values plus a simple beat detector.
|
||||
|
||||
class AudioEngine {
|
||||
constructor() {
|
||||
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.analyser = this.ctx.createAnalyser();
|
||||
this.analyser.fftSize = 2048;
|
||||
this.analyser.smoothingTimeConstant = 0.78;
|
||||
this.freq = new Uint8Array(this.analyser.frequencyBinCount);
|
||||
|
||||
// Output gain so a loaded file is audible; live input stays muted to
|
||||
// avoid feedback (it's already playing through the system).
|
||||
this.outGain = this.ctx.createGain();
|
||||
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.sourceGain = null; // per-source gain (for crossfades)
|
||||
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
|
||||
this.trimStart = 0; // playback start point (s); seek here on load
|
||||
this.trimEnd = 0; // playback end point (s); 0 = play to the natural end
|
||||
this._trimFired = false;
|
||||
this._videoSrc = null; // persistent source for the playlist video element
|
||||
this._videoGain = null;
|
||||
this._videoSrcEl = null;
|
||||
|
||||
// Smoothed band values and beat state.
|
||||
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);
|
||||
|
||||
// Time-domain waveform (oscilloscope), -1..1.
|
||||
this.NW = 256;
|
||||
this.wave = new Float32Array(this.NW);
|
||||
this.timeData = new Uint8Array(this.analyser.fftSize);
|
||||
|
||||
// Scrolling amplitude history (song-style waveform envelope).
|
||||
this.NH = 256;
|
||||
this.waveHist = new Float32Array(this.NH);
|
||||
this.histPeak = 0;
|
||||
this.histAccum = 0;
|
||||
this.PUSH_EVERY = 2.4; // frames between samples at scrollRate 1 (~40ms)
|
||||
this.scrollRate = 1; // tied to the visual speed slider
|
||||
}
|
||||
|
||||
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
||||
|
||||
// Trim the current/next track to [start, end] seconds (end 0 = natural end).
|
||||
setTrim(start, end) {
|
||||
this.trimStart = Math.max(0, start || 0);
|
||||
this.trimEnd = Math.max(0, end || 0);
|
||||
this._trimFired = false;
|
||||
}
|
||||
// Seek a freshly-loaded media element to the trim start (waits for metadata).
|
||||
seekToTrimStart(el) {
|
||||
const go = () => { if (this.trimStart > 0) { try { el.currentTime = this.trimStart; } catch (e) {} } };
|
||||
if (el.readyState >= 1) go(); else el.addEventListener('loadedmetadata', go, { once: true });
|
||||
}
|
||||
// Called every frame from the output loop: stop at the trim end point.
|
||||
checkTrim() {
|
||||
const el = this.mediaEl;
|
||||
if (!el || el.paused || this._trimFired) return;
|
||||
if (this.trimEnd > 0 && el.currentTime >= this.trimEnd) {
|
||||
this._trimFired = true;
|
||||
try { el.pause(); } catch (e) {}
|
||||
if (this.onEnded) this.onEnded();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any current audio/video source but keep the context & analyser alive
|
||||
// (meters read 0, recording keep-alive continues). Used for visual-only
|
||||
// playlist interludes.
|
||||
silence() {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
this.mediaEl = null;
|
||||
this.mode = 'silence';
|
||||
}
|
||||
|
||||
_disconnectSource() {
|
||||
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = null; }
|
||||
if (this.sourceGain) { try { this.sourceGain.disconnect(); } catch (e) {} this.sourceGain = null; }
|
||||
if (this.mediaEl) { this.mediaEl.pause(); this.mediaEl.src = ''; this.mediaEl = null; }
|
||||
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
||||
}
|
||||
|
||||
// Play a File/Blob, or a src URL (e.g. file://…), through the analyser and
|
||||
// the speakers. opts.loop controls looping; opts.crossfade (ms) fades the
|
||||
// previous file out while the new one fades in.
|
||||
async loadFile(fileOrSrc, opts = {}) {
|
||||
this.resume();
|
||||
const fadeMs = Math.max(0, opts.crossfade || 0);
|
||||
|
||||
const el = new Audio();
|
||||
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
||||
el.loop = opts.loop !== undefined ? opts.loop : true;
|
||||
el.crossOrigin = 'anonymous';
|
||||
el.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||
|
||||
const src = this.ctx.createMediaElementSource(el);
|
||||
const g = this.ctx.createGain();
|
||||
src.connect(g);
|
||||
g.connect(this.analyser);
|
||||
g.connect(this.outGain); // audible
|
||||
g.connect(this.recordDest); // captured by the recorder
|
||||
|
||||
if (fadeMs > 0 && this.mediaEl && this.mode === 'file' && this.sourceGain) {
|
||||
const now = this.ctx.currentTime, dur = fadeMs / 1000;
|
||||
g.gain.setValueAtTime(0.0001, now);
|
||||
g.gain.linearRampToValueAtTime(1, now + dur);
|
||||
const oldEl = this.mediaEl, oldSrc = this.sourceNode, oldGain = this.sourceGain;
|
||||
oldGain.gain.cancelScheduledValues(now);
|
||||
oldGain.gain.setValueAtTime(oldGain.gain.value, now);
|
||||
oldGain.gain.linearRampToValueAtTime(0.0001, now + dur);
|
||||
setTimeout(() => {
|
||||
try { oldEl.pause(); oldEl.src = ''; } catch (e) {}
|
||||
try { oldSrc.disconnect(); } catch (e) {}
|
||||
try { oldGain.disconnect(); } catch (e) {}
|
||||
}, fadeMs + 80);
|
||||
} else {
|
||||
this._disconnectSource();
|
||||
g.gain.value = 1;
|
||||
}
|
||||
|
||||
this.mediaEl = el;
|
||||
this.sourceNode = src;
|
||||
this.sourceGain = g;
|
||||
this.stream = null;
|
||||
this.mode = 'file';
|
||||
this._trimFired = false;
|
||||
|
||||
await el.play();
|
||||
this.seekToTrimStart(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
// Use a live input device (mic / line-in / BlackHole). Not routed to output
|
||||
// to avoid feedback loops.
|
||||
async useInput(deviceId) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId: deviceId ? { exact: deviceId } : undefined,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false
|
||||
},
|
||||
video: false
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.stream = stream;
|
||||
|
||||
const src = this.ctx.createMediaStreamSource(stream);
|
||||
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';
|
||||
}
|
||||
|
||||
// Play a playlist video track: route the given <video> element's audio
|
||||
// through the analyser/output so visuals react and it's audible/recordable.
|
||||
attachVideo(videoEl) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
if (this._videoSrcEl !== videoEl) {
|
||||
// createMediaElementSource may only be called once per element.
|
||||
this._videoSrc = this.ctx.createMediaElementSource(videoEl);
|
||||
this._videoGain = this.ctx.createGain();
|
||||
this._videoSrc.connect(this._videoGain);
|
||||
this._videoGain.connect(this.analyser);
|
||||
this._videoGain.connect(this.outGain);
|
||||
this._videoGain.connect(this.recordDest);
|
||||
this._videoSrcEl = videoEl;
|
||||
}
|
||||
videoEl.onended = () => { if (this.onEnded) this.onEnded(); };
|
||||
this.mediaEl = videoEl;
|
||||
this.sourceNode = null;
|
||||
this.sourceGain = null;
|
||||
this.mode = 'video';
|
||||
}
|
||||
|
||||
async listInputDevices() {
|
||||
// Labels are only populated after permission is granted.
|
||||
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 === '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; }
|
||||
this.mediaEl.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sample the analyser and update smoothed values. Call once per frame.
|
||||
update() {
|
||||
this.analyser.getByteFrequencyData(this.freq);
|
||||
const bins = this.freq.length; // 1024 bins, ~0..(sr/2)
|
||||
// Roughly: bass 0-6%, mid 6-25%, treble 25-65% of spectrum.
|
||||
const bassEnd = Math.floor(bins * 0.06);
|
||||
const midEnd = Math.floor(bins * 0.25);
|
||||
const trebEnd = Math.floor(bins * 0.65);
|
||||
|
||||
let b = 0, m = 0, t = 0;
|
||||
for (let i = 0; i < bassEnd; i++) b += this.freq[i];
|
||||
for (let i = bassEnd; i < midEnd; i++) m += this.freq[i];
|
||||
for (let i = midEnd; i < trebEnd; i++) t += this.freq[i];
|
||||
b = b / (bassEnd * 255);
|
||||
m = m / ((midEnd - bassEnd) * 255);
|
||||
t = t / ((trebEnd - midEnd) * 255);
|
||||
|
||||
const g = this.gain;
|
||||
// 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.
|
||||
this._bassAvg = this._bassAvg * 0.94 + b * 0.06;
|
||||
this._beatCooldown -= 1;
|
||||
if (b > this._bassAvg * 1.35 && b > 0.12 && this._beatCooldown <= 0) {
|
||||
this.beat = 1.0;
|
||||
this._beatCooldown = 8; // ~min frames between beats
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// Time-domain waveform.
|
||||
this.analyser.getByteTimeDomainData(this.timeData);
|
||||
const wstep = this.timeData.length / this.NW;
|
||||
let peak = 0;
|
||||
for (let i = 0; i < this.NW; i++) {
|
||||
const s = (this.timeData[Math.floor(i * wstep)] - 128) / 128;
|
||||
this.wave[i] = s;
|
||||
const a = Math.abs(s);
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
|
||||
// Scrolling waveform: accumulate the peak, push into history at a rate
|
||||
// controlled by the speed slider (so it reads as a song's waveform).
|
||||
this.histPeak = Math.max(this.histPeak, peak);
|
||||
this.histAccum += Math.max(0.04, this.scrollRate);
|
||||
while (this.histAccum >= this.PUSH_EVERY) {
|
||||
this.histAccum -= this.PUSH_EVERY;
|
||||
this.waveHist.copyWithin(0, 1);
|
||||
this.waveHist[this.NH - 1] = this.histPeak;
|
||||
this.histPeak = peak;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
_smooth(cur, target, attack, release) {
|
||||
const k = target > cur ? attack : release;
|
||||
return cur + (target - cur) * k;
|
||||
}
|
||||
|
||||
get values() {
|
||||
return { bass: this.bass, mid: this.mid, treble: this.treble, level: this.level, beat: this.beat, spectrum: this.spectrum, wave: this.wave, waveHist: this.waveHist };
|
||||
}
|
||||
}
|
||||
|
||||
window.AudioEngine = AudioEngine;
|
||||
Vendored
+110
@@ -0,0 +1,110 @@
|
||||
// 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 },
|
||||
{ name: 'Waveform', scale: 1.0, bgDark: true },
|
||||
{ name: 'Waveform Radiale', scale: 1.0, bgDark: true },
|
||||
{ name: 'Tri-Banda', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Bassi', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Medi', scale: 1.0, bgDark: true },
|
||||
{ name: 'Reattivo Alti', 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
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,414 @@
|
||||
// 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); }
|
||||
`;
|
||||
|
||||
const FRAG = `#version 300 es
|
||||
precision highp float;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform vec2 uRes;
|
||||
uniform float uTime;
|
||||
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 uWave[256]; // live time-domain waveform (-1..1)
|
||||
uniform float uWaveHist[256]; // scrolling amplitude history (song waveform)
|
||||
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);
|
||||
}
|
||||
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), 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; }
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
// ---- Waveform + band-reactive families ----
|
||||
float waveAt(int i) { return uWave[i]; }
|
||||
|
||||
// Scrolling song waveform: a mirrored filled envelope that moves with time,
|
||||
// like the waveform display in DJ software (newest sample at the right edge).
|
||||
float famWave(vec2 uv) {
|
||||
float xn = uv.x * 0.5 + 0.5;
|
||||
if (xn < 0.0 || xn > 1.0) return 0.0;
|
||||
float fx = xn * 255.0;
|
||||
int i0 = int(floor(fx));
|
||||
int i1 = min(i0 + 1, 255);
|
||||
float amp = mix(uWaveHist[i0], uWaveHist[i1], fract(fx)) * 0.85;
|
||||
float ay = abs(uv.y);
|
||||
float fill = smoothstep(amp, amp - 0.012, ay); // solid body
|
||||
float edge = smoothstep(0.014, 0.0, abs(ay - amp)); // bright crest
|
||||
// a faint centre line so silence still reads as a waveform
|
||||
float centre = smoothstep(0.006, 0.0, ay) * 0.4;
|
||||
return clamp(fill * 0.55 + edge + centre, 0.0, 1.5);
|
||||
}
|
||||
|
||||
// Radial oscilloscope: the waveform wrapped around a circle (interpolated).
|
||||
float famWaveCircle(vec2 uv) {
|
||||
float a = atan(uv.y, uv.x);
|
||||
float r = length(uv);
|
||||
float ft = (a / 6.2831853 + 0.5) * 255.0;
|
||||
int i0 = int(floor(clamp(ft, 0.0, 255.0)));
|
||||
int i1 = min(i0 + 1, 255);
|
||||
float w = mix(waveAt(i0), waveAt(i1), fract(ft));
|
||||
float radius = 0.42 + w * 0.2 * (0.6 + uLevel * aMix);
|
||||
float d = abs(r - radius);
|
||||
return smoothstep(0.012, 0.0, d) + 0.28 * smoothstep(0.07, 0.0, d);
|
||||
}
|
||||
|
||||
// Concentric zones, each reacting to a different band (inner=bass … outer=treble).
|
||||
float famTriBand(vec2 uv) {
|
||||
float r = length(uv);
|
||||
float band = r < 0.33 ? uBass : (r < 0.66 ? uMid : uTreble);
|
||||
float rings = 0.5 + 0.5 * sin(r * 30.0 - uT * 3.0);
|
||||
return rings * (0.18 + 1.6 * band * aMix) * smoothstep(1.1, 0.05, r);
|
||||
}
|
||||
|
||||
float famBass(vec2 uv) {
|
||||
float r = length(uv), b = uBass * aMix;
|
||||
float blob = smoothstep(0.7 + b * 0.6, 0.0, r);
|
||||
float rings = (0.5 + 0.5 * sin(r * 12.0 - uT * 2.0)) * smoothstep(1.2, 0.2, r);
|
||||
return (blob + rings * 0.6) * (0.3 + 1.4 * b);
|
||||
}
|
||||
float famMid(vec2 uv) {
|
||||
float r = length(uv), a = atan(uv.y, uv.x), m = uMid * aMix;
|
||||
float petals = 0.5 + 0.5 * sin(a * 6.0 + uT * 2.0 + r * 8.0);
|
||||
return petals * smoothstep(0.95, 0.0, r) * (0.25 + 1.6 * m);
|
||||
}
|
||||
float famTreble(vec2 uv) {
|
||||
float t = uTreble * aMix, r = length(uv);
|
||||
float g = hash(floor(uv * 42.0) + floor(vec2(uT * 8.0)));
|
||||
float sparkle = step(0.72, g) * g;
|
||||
return sparkle * (0.2 + 2.2 * t) * smoothstep(1.1, 0.1, r);
|
||||
}
|
||||
|
||||
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);
|
||||
if (f == 23) return famWave(uv);
|
||||
if (f == 24) return famWaveCircle(uv);
|
||||
if (f == 25) return famTriBand(uv);
|
||||
if (f == 26) return famBass(uv);
|
||||
if (f == 27) return famMid(uv);
|
||||
if (f == 28) return famTreble(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);
|
||||
// Brightness clearly pulses with the loudness and punches on the beat.
|
||||
hsv.z = pow(clamp(hsv.z, 0.0, 1.0), uContrast) * (0.5 + 1.3 * uLevel * aMix + 0.6 * uBeat * 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.35 + uLevel * 0.12) * aMix;
|
||||
col *= 1.0 - 0.28 * dot(uv0, uv0);
|
||||
return col;
|
||||
}
|
||||
|
||||
void main() {
|
||||
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.8 * aMix));
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
float v = field(uFamily, uv);
|
||||
fragColor = vec4(colorize(v, uv0), 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
window.SHADERS = { vert: VERT, frag: FRAG };
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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;
|
||||
// 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.program = this._buildProgram(window.SHADERS.vert, window.SHADERS.frag);
|
||||
|
||||
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;
|
||||
|
||||
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'), uWave: U('uWave'), uWaveHist: U('uWaveHist')
|
||||
};
|
||||
|
||||
// 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))
|
||||
throw new Error('Errore compilazione shader:\n' + gl.getShaderInfoLog(sh));
|
||||
return sh;
|
||||
}
|
||||
|
||||
_buildProgram(vsrc, fsrc) {
|
||||
const gl = this.gl;
|
||||
const p = gl.createProgram();
|
||||
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))
|
||||
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), 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);
|
||||
}
|
||||
|
||||
setEffect(effect) {
|
||||
// Merge onto current so partial updates (e.g. only family) are safe.
|
||||
this.effect = Object.assign({}, this.effect, effect);
|
||||
}
|
||||
|
||||
// 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;
|
||||
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(this.aPos);
|
||||
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
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);
|
||||
if (u.uWave && audio.wave) gl.uniform1fv(u.uWave, audio.wave);
|
||||
if (u.uWaveHist && audio.waveHist) gl.uniform1fv(u.uWaveHist, audio.waveHist);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.tex);
|
||||
gl.uniform1i(u.uTex, 0);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
window.Visualizer = Visualizer;
|
||||
Reference in new issue
Block a user