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:
lucianoandClaude Opus 4.8 committed 2026-06-19 12:44:17 +02:00
commit b32ea47ae5
14 files changed
+5748

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Dependencies
node_modules/
# Build output
dist/
out/
# Logs
*.log
npm-debug.log*
# macOS
.DS_Store
# Editor
.vscode/
.idea/
+75
View File
@@ -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).
+156
View File
@@ -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();
});
+4113
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+109
View File
@@ -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
View File
@@ -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' });
+29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+101
View File
@@ -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;