Initial commit: DJ Visualizer
Electron app with audio-reactive fractal/abstract visuals on an external display. Dual-window setup (control panel + fullscreen output), 8 WebGL scenes, audio from file/live input/system audio, image and scrolling-text overlays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b32ea47ae5
14 files changed
+5748
No files matched your search
+17
@@ -0,0 +1,17 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
out/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,75 @@
|
||||
# DJ Visualizer
|
||||
|
||||
App desktop (Electron) per Mac che mostra **frattali e visual astratti reattivi all'audio** su uno schermo esterno. L'audio può arrivare da un **file caricato**, da un **input live** (microfono / line-in) o dall'**audio di sistema** (il tuo set DJ) tramite un device virtuale tipo BlackHole. Puoi sovrapporre **immagini** e **scritte scorrevoli**.
|
||||
|
||||
## Due finestre
|
||||
|
||||
All'avvio si aprono **due finestre**:
|
||||
|
||||
- **Controlli** — il pannello con tutte le funzioni (sorgente audio, scene, slider, immagini, testo, scelta monitor). Sta sul tuo schermo principale.
|
||||
- **Output** — solo la visualizzazione, senza interfaccia. Va automaticamente a schermo intero sul monitor esterno se presente.
|
||||
|
||||
Comandi e tasti agiscono sul pannello Controlli e si riflettono sull'Output. Chiudendo la finestra Controlli si chiude tutta l'app.
|
||||
|
||||
## Avvio
|
||||
|
||||
```bash
|
||||
npm install # solo la prima volta
|
||||
npm start
|
||||
```
|
||||
|
||||
## Controlli rapidi (tastiera)
|
||||
|
||||
| Tasto | Azione |
|
||||
|-------|--------|
|
||||
| `1` `2` `3` | Cambia scena (Frattale · Plasma · Tunnel) |
|
||||
| `F` | Schermo intero |
|
||||
| `H` | Mostra/nascondi pannello |
|
||||
| `O` | Apri file audio |
|
||||
| `I` | Usa input live selezionato |
|
||||
| `G` | Carica immagini |
|
||||
| `T` | Mostra/nascondi testo scorrevole |
|
||||
| `← →` | Cambia immagine |
|
||||
| `Spazio` | Play/pausa del file |
|
||||
|
||||
Puoi anche **trascinare** un brano (mp3/wav) o delle immagini direttamente sulla finestra.
|
||||
|
||||
## Schermo esterno
|
||||
|
||||
Nel pannello, sezione **Schermo**: scegli il monitor (di default seleziona quello esterno) e premi **Vai** → la finestra si sposta e va a schermo intero su quel display. `F` per uscire.
|
||||
|
||||
## Sorgenti audio
|
||||
|
||||
- **File**: pulsante 📂 o trascina il file. L'audio si sente dalle casse del Mac.
|
||||
- **Input live (mic / line-in)**: seleziona il device dal menu e premi *Usa input*. Utile con una scheda audio/mixer collegato all'ingresso.
|
||||
- **Audio di sistema (il tuo set)**: serve un device virtuale, vedi sotto.
|
||||
|
||||
### Catturare l'audio di sistema con BlackHole
|
||||
|
||||
macOS non permette di catturare direttamente l'audio in uscita. Soluzione gratuita: **BlackHole**.
|
||||
|
||||
1. Installa BlackHole (2ch):
|
||||
```bash
|
||||
brew install blackhole-2ch
|
||||
```
|
||||
(oppure scaricalo da https://existential.audio/blackhole/)
|
||||
|
||||
2. Per **sentire l'audio E inviarlo al visualizer** allo stesso tempo, crea un **Multi-Output Device** in *Audio MIDI Setup* (Configurazione MIDI Audio):
|
||||
- `+` in basso a sinistra → **Create Multi-Output Device**
|
||||
- spunta sia le tue **casse/cuffie** sia **BlackHole 2ch**
|
||||
- imposta questo Multi-Output come uscita del Mac (o del software DJ)
|
||||
|
||||
3. In DJ Visualizer scegli **BlackHole 2ch** come input live e premi *Usa input*.
|
||||
|
||||
> In alternativa, manda direttamente l'uscita del software DJ a BlackHole se preferisci non sentire nulla dalle casse del Mac.
|
||||
|
||||
## Personalizzare i visual
|
||||
|
||||
Le scene sono shader GLSL in `src/shaders.js` (array `SHADERS.scenes`). Ognuna riceve uniform audio: `uBass`, `uMid`, `uTreble`, `uLevel`, `uBeat`, più `uTime` e `uRes`. Aggiungere una scena = aggiungere uno shader all'array e un bottone in `index.html`.
|
||||
|
||||
## Build di un .app/.dmg
|
||||
|
||||
```bash
|
||||
npm run dist
|
||||
```
|
||||
Genera un `.dmg` in `dist/` (richiede electron-builder, già incluso).
|
||||
@@ -0,0 +1,156 @@
|
||||
const { app, BrowserWindow, screen, ipcMain, systemPreferences } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
let controlWin = null; // panel with all the controls (primary screen)
|
||||
let outputWin = null; // pure visualization (external screen, fullscreen)
|
||||
|
||||
function externalDisplay() {
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
return screen.getAllDisplays().find(d => d.id !== primary.id) || primary;
|
||||
}
|
||||
|
||||
function createWindows() {
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
const ext = externalDisplay();
|
||||
const hasExternal = ext.id !== primary.id;
|
||||
|
||||
console.log(`[DJV] Schermi rilevati: ${screen.getAllDisplays().length}` +
|
||||
` — esterno ${hasExternal ? 'SÌ' : 'NO'} (output su ${hasExternal ? 'monitor esterno' : 'principale, finestra'})`);
|
||||
|
||||
// --- Control window (the panel) ---
|
||||
controlWin = new BrowserWindow({
|
||||
width: 580,
|
||||
height: 880,
|
||||
minWidth: 420,
|
||||
x: primary.bounds.x + 60,
|
||||
y: primary.bounds.y + 60,
|
||||
title: 'DJ Visualizer — Controlli',
|
||||
backgroundColor: '#0e0e16',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
additionalArguments: ['--role=control']
|
||||
}
|
||||
});
|
||||
controlWin.loadFile(path.join(__dirname, 'src', 'control.html'));
|
||||
|
||||
// --- Output window (the visuals) ---
|
||||
outputWin = new BrowserWindow({
|
||||
width: hasExternal ? ext.bounds.width : 960,
|
||||
height: hasExternal ? ext.bounds.height : 600,
|
||||
x: hasExternal ? ext.bounds.x : primary.bounds.x + 520,
|
||||
y: hasExternal ? ext.bounds.y : primary.bounds.y + 60,
|
||||
title: 'DJ Visualizer — Output',
|
||||
backgroundColor: '#000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
additionalArguments: ['--role=output'],
|
||||
// Let audio loaded via IPC play without a click inside the output window.
|
||||
autoplayPolicy: 'no-user-gesture-required'
|
||||
}
|
||||
});
|
||||
outputWin.loadFile(path.join(__dirname, 'src', 'output.html'));
|
||||
if (hasExternal) {
|
||||
outputWin.once('ready-to-show', () => placeOutputOnDisplay(ext, true));
|
||||
}
|
||||
|
||||
controlWin.on('closed', () => {
|
||||
controlWin = null;
|
||||
if (outputWin) outputWin.close();
|
||||
app.quit();
|
||||
});
|
||||
outputWin.on('closed', () => { outputWin = null; });
|
||||
}
|
||||
|
||||
async function ensureMicAccess() {
|
||||
if (process.platform !== 'darwin') return;
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
if (status !== 'granted') await systemPreferences.askForMediaAccess('microphone');
|
||||
} catch (e) { /* getUserMedia will prompt again if needed */ }
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await ensureMicAccess();
|
||||
createWindows();
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindows();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||||
|
||||
// --- IPC routing between the two windows --------------------------------
|
||||
|
||||
// Control -> Output (commands).
|
||||
ipcMain.on('ctl', (_e, msg) => {
|
||||
if (outputWin && !outputWin.isDestroyed()) outputWin.webContents.send('ctl', msg);
|
||||
});
|
||||
|
||||
// Output -> Control (reports: meters, device list, play state).
|
||||
ipcMain.on('rpt', (_e, msg) => {
|
||||
if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('rpt', msg);
|
||||
});
|
||||
|
||||
// --- Display management --------------------------------------------------
|
||||
|
||||
ipcMain.handle('displays:list', () => {
|
||||
const primary = screen.getPrimaryDisplay();
|
||||
return screen.getAllDisplays().map((d, i) => ({
|
||||
id: d.id,
|
||||
index: i,
|
||||
label: d.label || `Display ${i + 1}`,
|
||||
bounds: d.bounds,
|
||||
isPrimary: d.id === primary.id
|
||||
}));
|
||||
});
|
||||
|
||||
// Place the output window on a given display. On macOS we use *simple*
|
||||
// fullscreen (a borderless window covering the screen) instead of native
|
||||
// fullscreen: native fullscreen creates a Space tied to whichever display the
|
||||
// window happens to be on and is unreliable when moving between monitors.
|
||||
function placeOutputOnDisplay(target, fullscreen) {
|
||||
if (!outputWin || !target) return;
|
||||
// Leave any current fullscreen first so the window can be repositioned.
|
||||
if (process.platform === 'darwin') {
|
||||
if (outputWin.isSimpleFullScreen()) outputWin.setSimpleFullScreen(false);
|
||||
} else if (outputWin.isFullScreen()) {
|
||||
outputWin.setFullScreen(false);
|
||||
}
|
||||
|
||||
// Move onto the target display.
|
||||
outputWin.setBounds({ ...target.bounds });
|
||||
outputWin.show();
|
||||
outputWin.focus();
|
||||
|
||||
if (!fullscreen) return;
|
||||
// Cover the target display once the move has settled.
|
||||
setTimeout(() => {
|
||||
if (!outputWin) return;
|
||||
outputWin.setBounds({ ...target.bounds });
|
||||
if (process.platform === 'darwin') outputWin.setSimpleFullScreen(true);
|
||||
else outputWin.setFullScreen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Move the OUTPUT window to a display and go fullscreen there.
|
||||
ipcMain.handle('output:moveTo', (_e, displayId) => {
|
||||
const target = screen.getAllDisplays().find(d => d.id === displayId);
|
||||
if (!target) return false;
|
||||
placeOutputOnDisplay(target, true);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('output:toggleFullscreen', () => {
|
||||
if (!outputWin) return false;
|
||||
if (process.platform === 'darwin') {
|
||||
const next = !outputWin.isSimpleFullScreen();
|
||||
outputWin.setSimpleFullScreen(next);
|
||||
return next;
|
||||
}
|
||||
outputWin.setFullScreen(!outputWin.isFullScreen());
|
||||
return outputWin.isFullScreen();
|
||||
});
|
||||
Generated
+4113
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dj-visualizer",
|
||||
"version": "1.0.0",
|
||||
"description": "Audio-reactive fractal & abstract visualizer for external display (Mac)",
|
||||
"main": "main.js",
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dist": "electron-builder --mac"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.djvisualizer.app",
|
||||
"productName": "DJ Visualizer",
|
||||
"mac": {
|
||||
"category": "public.app-category.music",
|
||||
"target": "dmg"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"src/**/*"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^31.0.0",
|
||||
"electron-builder": "^24.13.3"
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||
|
||||
// Each window is launched with --role=control or --role=output.
|
||||
const roleArg = process.argv.find(a => a.startsWith('--role='));
|
||||
const role = roleArg ? roleArg.split('=')[1] : 'control';
|
||||
|
||||
contextBridge.exposeInMainWorld('djv', {
|
||||
role,
|
||||
// Control -> Output commands.
|
||||
send: (msg) => ipcRenderer.send('ctl', msg),
|
||||
onControl: (cb) => ipcRenderer.on('ctl', (_e, m) => cb(m)),
|
||||
// Output -> Control reports.
|
||||
report: (msg) => ipcRenderer.send('rpt', msg),
|
||||
onReport: (cb) => ipcRenderer.on('rpt', (_e, m) => cb(m)),
|
||||
// Display management (control window).
|
||||
// Resolve a dropped/picked File to its absolute filesystem path.
|
||||
pathForFile: (file) => {
|
||||
try { return webUtils.getPathForFile(file); }
|
||||
catch (e) { return file && file.path ? file.path : null; }
|
||||
},
|
||||
listDisplays: () => ipcRenderer.invoke('displays:list'),
|
||||
moveOutputTo: (id) => ipcRenderer.invoke('output:moveTo', id),
|
||||
toggleOutputFullscreen: () => ipcRenderer.invoke('output:toggleFullscreen')
|
||||
});
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// 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);
|
||||
|
||||
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';
|
||||
|
||||
// Smoothed band values and beat state.
|
||||
this.gain = 1.0; // user reactivity multiplier
|
||||
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;
|
||||
}
|
||||
|
||||
resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); }
|
||||
|
||||
_disconnectSource() {
|
||||
if (this.sourceNode) { try { this.sourceNode.disconnect(); } catch (e) {} this.sourceNode = 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.
|
||||
async loadFile(fileOrSrc) {
|
||||
this.resume();
|
||||
this._disconnectSource();
|
||||
|
||||
const el = new Audio();
|
||||
el.src = typeof fileOrSrc === 'string' ? fileOrSrc : URL.createObjectURL(fileOrSrc);
|
||||
el.loop = true;
|
||||
el.crossOrigin = 'anonymous';
|
||||
this.mediaEl = el;
|
||||
|
||||
const src = this.ctx.createMediaElementSource(el);
|
||||
src.connect(this.analyser);
|
||||
src.connect(this.outGain); // file audio is audible
|
||||
this.sourceNode = src;
|
||||
this.mode = 'file';
|
||||
|
||||
await el.play();
|
||||
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
|
||||
this.sourceNode = src;
|
||||
this.mode = 'input';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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) 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);
|
||||
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
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
window.AudioEngine = AudioEngine;
|
||||
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
|
||||
<title>DJ Visualizer — Controlli</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="control">
|
||||
<header id="topbar">
|
||||
<strong>🎛 DJ Visualizer</strong>
|
||||
<span id="status"><span id="fps">–</span> · <span id="scene-name">Frattale</span></span>
|
||||
</header>
|
||||
|
||||
<main id="panel-grid">
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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="span2">
|
||||
<label>Scena</label>
|
||||
<div id="scene-buttons" class="scene-grid"></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 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>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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
</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
|
||||
</footer>
|
||||
|
||||
<script src="shaders.js"></script>
|
||||
<script src="control.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
// CONTROL window: builds the UI and sends commands to the output window.
|
||||
// It also receives reports (meters, fps, device list, play state).
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
sceneContainer.addEventListener('click', (e) => {
|
||||
const b = e.target.closest('button');
|
||||
if (b) setScene(parseInt(b.dataset.scene, 10));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 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';
|
||||
}
|
||||
});
|
||||
$('#btn-play').addEventListener('click', () => send({ type: 'togglePlay' }));
|
||||
$('#btn-use-input').addEventListener('click', () =>
|
||||
send({ type: 'useInput', deviceId: $('#device-select').value || null }));
|
||||
|
||||
// ---------------------------------------------------------------- parameters
|
||||
$('#gain').addEventListener('input', (e) => {
|
||||
$('#gain-val').textContent = parseFloat(e.target.value).toFixed(1) + '×';
|
||||
send({ type: 'gain', value: parseFloat(e.target.value) });
|
||||
});
|
||||
$('#speed').addEventListener('input', (e) => {
|
||||
$('#speed-val').textContent = parseFloat(e.target.value).toFixed(1) + '×';
|
||||
send({ type: 'speed', value: parseFloat(e.target.value) });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- images
|
||||
$('#btn-images').addEventListener('click', () => $('#image-input').click());
|
||||
$('#image-input').addEventListener('change', (e) => {
|
||||
const paths = [...e.target.files].map(filePath).filter(Boolean);
|
||||
if (paths.length) send({ type: 'addImages', paths });
|
||||
});
|
||||
$('#btn-images-clear').addEventListener('click', () => send({ type: 'clearImages' }));
|
||||
$('#img-blend').addEventListener('change', (e) => send({ type: 'imgBlend', value: e.target.value }));
|
||||
$('#img-slideshow').addEventListener('change', (e) => send({ type: 'slideshow', on: e.target.checked }));
|
||||
$('#img-beat').addEventListener('change', (e) => send({ type: 'imgBeat', on: e.target.checked }));
|
||||
$('#img-size').addEventListener('input', (e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
$('#img-size-val').textContent = v + '%';
|
||||
send({ type: 'imgSize', value: v });
|
||||
});
|
||||
$('#img-interval').addEventListener('input', (e) => {
|
||||
const s = parseFloat(e.target.value);
|
||||
$('#img-interval-val').textContent = s.toFixed(1) + 's';
|
||||
send({ type: 'slideshowInterval', ms: s * 1000 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- ticker
|
||||
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value }));
|
||||
$('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked }));
|
||||
$('#ticker-pos').addEventListener('change', (e) => send({ type: 'tickerPos', pos: e.target.value }));
|
||||
$('#ticker-speed').addEventListener('input', (e) => {
|
||||
const m = parseFloat(e.target.value);
|
||||
$('#ticker-speed-val').textContent = m.toFixed(1) + '×';
|
||||
send({ type: 'tickerSpeed', mult: m });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- displays
|
||||
async function refreshDisplays() {
|
||||
const list = await djv.listDisplays();
|
||||
const sel = $('#display-select');
|
||||
sel.innerHTML = '';
|
||||
list.forEach(d => {
|
||||
const o = document.createElement('option');
|
||||
o.value = d.id;
|
||||
o.textContent = d.label + (d.isPrimary ? ' (principale)' : '') + ` — ${d.bounds.width}×${d.bounds.height}`;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
const ext = list.find(d => !d.isPrimary);
|
||||
if (ext) sel.value = ext.id;
|
||||
}
|
||||
$('#btn-display').addEventListener('click', () => {
|
||||
const id = parseInt($('#display-select').value, 10);
|
||||
if (id) djv.moveOutputTo(id);
|
||||
});
|
||||
$('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen());
|
||||
|
||||
// ---------------------------------------------------------------- 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'); });
|
||||
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 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 (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- keyboard
|
||||
window.addEventListener('keydown', (e) => {
|
||||
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()) {
|
||||
case 'f': djv.toggleOutputFullscreen(); break;
|
||||
case 'o': $('#file-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;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- reports in
|
||||
djv.onReport((m) => {
|
||||
switch (m.type) {
|
||||
case 'devices': {
|
||||
const sel = $('#device-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
|
||||
m.list.forEach(d => {
|
||||
const o = document.createElement('option');
|
||||
o.value = d.deviceId;
|
||||
o.textContent = d.label || 'Input 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) + '%';
|
||||
$('#m-treble').style.width = (m.treble * 100).toFixed(0) + '%';
|
||||
$('#fps').textContent = m.fps.toFixed(0) + ' fps';
|
||||
break;
|
||||
case 'playState':
|
||||
$('#btn-play').disabled = false;
|
||||
$('#btn-play').textContent = m.playing ? '⏸ Pausa' : '▶ Play';
|
||||
break;
|
||||
case 'fileLoaded':
|
||||
$('#btn-play').disabled = false;
|
||||
break;
|
||||
case 'error':
|
||||
console.warn('output error:', m.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- init
|
||||
refreshDisplays();
|
||||
window.addEventListener('focus', refreshDisplays);
|
||||
send({ type: 'refreshDevices' });
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' blob: data: file:; media-src 'self' blob: file:; style-src 'self' 'unsafe-inline'; script-src 'self'" />
|
||||
<title>DJ Visualizer — Output</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="output">
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<!-- Image overlay layer -->
|
||||
<div id="overlay-images"></div>
|
||||
|
||||
<!-- Scrolling text ticker -->
|
||||
<div id="ticker"><div id="ticker-track"><span></span><span></span></div></div>
|
||||
|
||||
<div id="drop-hint">
|
||||
<div class="big">🎛 In attesa dell'audio…</div>
|
||||
<div class="small">Usa il pannello di controllo sull'altro schermo</div>
|
||||
</div>
|
||||
|
||||
<script src="shaders.js"></script>
|
||||
<script src="visualizer.js"></script>
|
||||
<script src="audio.js"></script>
|
||||
<script src="output.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
// OUTPUT window: renders the visuals + overlays and runs the audio engine.
|
||||
// It receives commands from the control window via window.djv.onControl and
|
||||
// sends back reports (meters, fps, device list, play state).
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
const canvas = $('#gl');
|
||||
let viz;
|
||||
try {
|
||||
viz = new Visualizer(canvas);
|
||||
} catch (e) {
|
||||
document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">' +
|
||||
'Errore grafica:<br><pre>' + e.message + '</pre></div>';
|
||||
throw e;
|
||||
}
|
||||
|
||||
const audio = new AudioEngine();
|
||||
let speed = 1.0;
|
||||
|
||||
// Convert an absolute filesystem path into a file:// URL the renderer can load.
|
||||
function toFileURL(p) {
|
||||
return encodeURI('file://' + p).replace(/#/g, '%23').replace(/\?/g, '%3F');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- overlays
|
||||
const imgLayer = $('#overlay-images');
|
||||
let images = [];
|
||||
let imgIndex = 0;
|
||||
let slideshow = false, slideshowTimer = null, slideshowMs = 5000;
|
||||
let beatPulse = true;
|
||||
let imgBlend = 'screen';
|
||||
|
||||
function addImages(paths) {
|
||||
paths.forEach(p => {
|
||||
const img = document.createElement('img');
|
||||
img.src = toFileURL(p);
|
||||
img.style.mixBlendMode = imgBlend;
|
||||
imgLayer.appendChild(img);
|
||||
images.push(img);
|
||||
});
|
||||
if (images.length) showImage(images.length - paths.length);
|
||||
hideHint();
|
||||
}
|
||||
function showImage(i) {
|
||||
if (!images.length) return;
|
||||
imgIndex = (i + images.length) % images.length;
|
||||
images.forEach((im, idx) => im.classList.toggle('show', idx === imgIndex));
|
||||
}
|
||||
function clearImages() {
|
||||
images.forEach(im => im.remove());
|
||||
images = []; imgIndex = 0;
|
||||
}
|
||||
function restartSlideshow() {
|
||||
if (slideshowTimer) { clearInterval(slideshowTimer); slideshowTimer = null; }
|
||||
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
|
||||
}
|
||||
|
||||
const ticker = $('#ticker');
|
||||
const tickerTrack = $('#ticker-track');
|
||||
const tickerSpans = ticker.querySelectorAll('span');
|
||||
function setTickerText(t) { tickerSpans.forEach(s => s.textContent = t + ' • '); }
|
||||
function setTickerSpeed(m) { tickerTrack.style.setProperty('--ticker-dur', (18 / m) + 's'); }
|
||||
ticker.classList.add('pos-bottom');
|
||||
setTickerSpeed(1);
|
||||
|
||||
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 })) });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- command bus
|
||||
djv.onControl(async (m) => {
|
||||
switch (m.type) {
|
||||
case 'scene': viz.setScene(m.index); break;
|
||||
case 'gain': audio.gain = m.value; break;
|
||||
case 'speed': speed = m.value; break;
|
||||
|
||||
case 'loadFile':
|
||||
try {
|
||||
await audio.loadFile(toFileURL(m.path));
|
||||
hideHint();
|
||||
djv.report({ type: 'fileLoaded' });
|
||||
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 });
|
||||
break;
|
||||
}
|
||||
case 'useInput':
|
||||
try { await audio.useInput(m.deviceId || null); hideHint(); await reportDevices(); }
|
||||
catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||||
break;
|
||||
case 'refreshDevices': reportDevices(); break;
|
||||
|
||||
case 'addImages': addImages(m.paths); break;
|
||||
case 'clearImages': clearImages(); break;
|
||||
case 'imgBlend': imgBlend = m.value; images.forEach(im => im.style.mixBlendMode = m.value); break;
|
||||
case 'slideshow': slideshow = m.on; restartSlideshow(); break;
|
||||
case 'slideshowInterval': slideshowMs = m.ms; restartSlideshow(); break;
|
||||
case 'imgSize': imgLayer.style.setProperty('--img-size', m.value); break;
|
||||
case 'imgBeat': beatPulse = m.on; break;
|
||||
case 'imgNext': showImage(imgIndex + 1); break;
|
||||
case 'imgPrev': showImage(imgIndex - 1); break;
|
||||
|
||||
case 'tickerText': setTickerText(m.text); break;
|
||||
case 'tickerOn': ticker.classList.toggle('show', m.on); break;
|
||||
case 'tickerPos':
|
||||
ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
|
||||
ticker.classList.add('pos-' + m.pos);
|
||||
break;
|
||||
case 'tickerSpeed': setTickerSpeed(m.mult); break;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- render loop
|
||||
const startTime = performance.now();
|
||||
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0;
|
||||
|
||||
function frame() {
|
||||
const t = (performance.now() - startTime) / 1000;
|
||||
audio.update();
|
||||
const a = audio.values;
|
||||
viz.render(t * speed, a);
|
||||
|
||||
// Overlays react to audio.
|
||||
imgLayer.style.setProperty('--beat-scale',
|
||||
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
|
||||
tickerTrack.style.setProperty('--ticker-glow', (a.level * 24 + a.beat * 20).toFixed(0) + 'px');
|
||||
|
||||
// FPS + meter report to the control window (throttled to ~15 Hz).
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
|
||||
if (now - lastReport > 66) {
|
||||
lastReport = now;
|
||||
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// Populate the device list once at startup so the control dropdown fills in.
|
||||
reportDevices();
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
// GLSL ES 3.00 shaders for the visualizer. Stored as strings, compiled at runtime.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- Scene 0: Julia / Mandelbrot-style fractal -------------------------
|
||||
const FRAG_FRACTAL = FRAG_HEADER + `
|
||||
void main() {
|
||||
vec2 uv = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- 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]
|
||||
};
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #e8e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Helvetica, Arial, sans-serif;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
body.hide-cursor { cursor: none; }
|
||||
|
||||
#gl {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============ CONTROL WINDOW LAYOUT ============ */
|
||||
body.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow-x: hidden; /* never a horizontal scrollbar */
|
||||
overflow-y: auto;
|
||||
background: #0e0e16;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#topbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(20,20,30,0.6);
|
||||
}
|
||||
#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;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
#panel-grid 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; }
|
||||
|
||||
@media (max-width: 460px) {
|
||||
#panel-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
body.control label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
opacity: 0.6;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
body.control label:first-child { margin-top: 0; }
|
||||
body.control label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; }
|
||||
body.control .chk { text-transform: none; letter-spacing: 0; opacity: 0.85; margin: 0; }
|
||||
|
||||
#hints {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.5;
|
||||
line-height: 1.7;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
body.control select { width: 100%; max-width: 100%; }
|
||||
|
||||
/* ============ OUTPUT WINDOW ============ */
|
||||
body.output { cursor: none; background: #000; }
|
||||
|
||||
/* ---- Image overlay layer ---- */
|
||||
#overlay-images {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
--beat-scale: 1;
|
||||
}
|
||||
#overlay-images img {
|
||||
position: absolute;
|
||||
max-width: calc(var(--img-size, 70) * 1vw);
|
||||
max-height: calc(var(--img-size, 70) * 1vh);
|
||||
object-fit: contain;
|
||||
mix-blend-mode: screen;
|
||||
opacity: 0;
|
||||
transform: scale(var(--beat-scale));
|
||||
transition: opacity 0.8s ease;
|
||||
filter: drop-shadow(0 0 30px rgba(0,0,0,0.6));
|
||||
}
|
||||
#overlay-images img.show { opacity: 1; }
|
||||
|
||||
/* ---- Scrolling text ticker ---- */
|
||||
#ticker {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
padding: 14px 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.35), transparent);
|
||||
}
|
||||
#ticker.show { display: block; }
|
||||
#ticker.pos-bottom { bottom: 4vh; }
|
||||
#ticker.pos-top { top: 4vh; }
|
||||
#ticker.pos-middle { top: 50%; transform: translateY(-50%); }
|
||||
|
||||
#ticker-track {
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
animation: ticker-scroll var(--ticker-dur, 18s) linear infinite;
|
||||
}
|
||||
#ticker-track span {
|
||||
font-size: 6vh;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
padding: 0 4vw;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 calc(8px + var(--ticker-glow, 0px)) rgba(140,180,255,0.9),
|
||||
0 2px 30px rgba(0,0,0,0.8);
|
||||
}
|
||||
@keyframes ticker-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* ---- Drop hint ---- */
|
||||
#drop-hint {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.8);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
#drop-hint .big { font-size: 34px; font-weight: 600; }
|
||||
#drop-hint .small { font-size: 15px; opacity: 0.7; }
|
||||
#drop-hint.hidden { opacity: 0; }
|
||||
|
||||
body.dragover #drop-hint { opacity: 1; }
|
||||
body.dragover::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 18px;
|
||||
border: 3px dashed rgba(120,170,255,0.8);
|
||||
border-radius: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Control panel ---- */
|
||||
#panel {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
background: rgba(14, 14, 22, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
#panel.hidden { opacity: 0; transform: translateX(-12px); pointer-events: none; }
|
||||
|
||||
#panel header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#panel header strong { font-size: 15px; letter-spacing: 0.3px; }
|
||||
#fps { font-size: 11px; opacity: 0.55; font-variant-numeric: tabular-nums; }
|
||||
|
||||
#panel section { margin-bottom: 14px; }
|
||||
#panel label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
#panel label span { float: right; opacity: 0.9; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
.row { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||
|
||||
.scene-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.scene-grid button { font-size: 12px; padding: 7px 6px; text-align: left; }
|
||||
|
||||
button, select {
|
||||
font: inherit;
|
||||
color: #e8e8f0;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 9px;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover, select:hover { background: rgba(255,255,255,0.12); }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
button.active {
|
||||
background: rgba(110,150,255,0.28);
|
||||
border-color: rgba(120,170,255,0.7);
|
||||
}
|
||||
button.wide { width: 100%; }
|
||||
|
||||
select { appearance: none; }
|
||||
|
||||
.chk {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
opacity: 0.85 !important;
|
||||
font-size: 12px;
|
||||
margin: 0 !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chk input { accent-color: #7aa2ff; cursor: pointer; }
|
||||
|
||||
.textfield {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: #e8e8f0;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 9px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.textfield::placeholder { color: rgba(232,232,240,0.4); }
|
||||
|
||||
input[type=range] {
|
||||
width: 100%;
|
||||
accent-color: #7aa2ff;
|
||||
}
|
||||
|
||||
/* ---- Meters ---- */
|
||||
.meters { display: flex; flex-direction: column; gap: 6px; }
|
||||
.meter { display: flex; align-items: center; gap: 8px; }
|
||||
.meter span { width: 38px; font-size: 10px; opacity: 0.6; }
|
||||
.meter .bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meter .bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
|
||||
#panel footer {
|
||||
font-size: 10.5px;
|
||||
opacity: 0.5;
|
||||
line-height: 1.7;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// WebGL2 engine: renders a fullscreen quad through one of the scene shaders.
|
||||
|
||||
class Visualizer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, 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;
|
||||
|
||||
// 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')
|
||||
};
|
||||
});
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
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);
|
||||
const 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));
|
||||
}
|
||||
|
||||
// audio: { bass, mid, treble, level, beat }
|
||||
render(timeSec, audio) {
|
||||
const gl = this.gl;
|
||||
const p = this.programs[this.sceneIndex];
|
||||
const l = this.locs[this.sceneIndex];
|
||||
gl.useProgram(p);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
|
||||
gl.enableVertexAttribArray(l.aPos);
|
||||
gl.vertexAttribPointer(l.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.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
}
|
||||
|
||||
window.Visualizer = Visualizer;
|
||||
Reference in new issue
Block a user