Files
djAudiovisualizaerPi/server.js
T
lucianoandClaude Opus 4.8 b94c787231 DJ Visualizer Pi: standalone Node+WebSocket server with mobile PWA remote
New separate project (the Electron Mac app stays untouched). Reuses the visual
engine (WebGL shaders, effect catalog, Web Audio) verbatim and replaces Electron
IPC with a Node HTTP+WebSocket server:

- server.js: serves the kiosk output page + mobile control PWA, relays the same
  ctl/rpt JSON protocol over WebSocket, plus REST for media library/upload,
  JSON state (pads), waveform peaks and MP4 recording (ffmpeg).
- public/js/net.js: window.djv shim over WebSocket+HTTP so output.js runs intact.
- public/index.html + control.js: touch-first mobile remote (effects, media/queue,
  ticker, pads, audio/EQ, fullscreen + recording).
- PWA (manifest + service worker + generated icons).
- scripts/: Chromium kiosk launcher + install-pi.sh (systemd units for Pi 5).

Verified: REST endpoints, WebSocket relay both directions, control PWA loads the
2958-effect catalog and renders. (Headless WebGL output not testable without GPU.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:06:15 +02:00

174 lines
7.6 KiB
JavaScript

// DJ Visualizer Pi — standalone server.
// Serves the visual output page (Chromium kiosk on the Pi's HDMI) and the mobile
// control PWA, and relays JSON commands between them over WebSocket. Also exposes
// a small REST API for the media library, JSON state, waveform peaks and MP4
// recording. The control<->output message protocol is the SAME JSON used by the
// Electron app (ctl/rpt), so the engine code is reused almost unchanged.
const express = require('express');
const http = require('http');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFile, spawn } = require('child_process');
const multer = require('multer');
const { WebSocketServer } = require('ws');
const PORT = process.env.PORT || 8080;
const ROOT = __dirname;
const MEDIA_DIR = process.env.MEDIA_DIR || path.join(ROOT, 'media');
const STATE_DIR = path.join(ROOT, 'state');
const SVG_DIR = path.join(ROOT, 'svg');
const REC_DIR = process.env.REC_DIR || path.join(ROOT, 'recordings');
for (const d of [MEDIA_DIR, STATE_DIR, REC_DIR]) fs.mkdirSync(d, { recursive: true });
function ffmpegPath() {
const cands = [process.env.FFMPEG, 'ffmpeg', '/usr/bin/ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'];
for (const c of cands) { if (!c) continue; if (c === 'ffmpeg' || fs.existsSync(c)) return c; }
return 'ffmpeg';
}
const app = express();
app.use(express.json({ limit: '4mb' }));
app.use(express.static(path.join(ROOT, 'public')));
app.use('/media', express.static(MEDIA_DIR));
app.use('/svg', express.static(SVG_DIR));
// ---- Media library ---------------------------------------------------------
const AUDIO_RE = /\.(mp3|m4a|aac|wav|flac|ogg|opus|aif|aiff)$/i;
const VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv)$/i;
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg)$/i;
app.get('/api/media', (_req, res) => {
let files = [];
try { files = fs.readdirSync(MEDIA_DIR); } catch (e) {}
const items = files.filter(f => AUDIO_RE.test(f) || VIDEO_RE.test(f) || IMAGE_RE.test(f))
.sort((a, b) => a.localeCompare(b))
.map(f => ({
name: f,
url: '/media/' + encodeURIComponent(f),
kind: VIDEO_RE.test(f) ? 'video' : (IMAGE_RE.test(f) ? 'image' : 'audio')
}));
res.json(items);
});
const upload = multer({
storage: multer.diskStorage({
destination: (_r, _f, cb) => cb(null, MEDIA_DIR),
filename: (_r, file, cb) => cb(null, file.originalname)
}),
limits: { fileSize: 1024 * 1024 * 1024 }
});
app.post('/api/upload', upload.array('files'), (req, res) => {
res.json({ ok: true, count: (req.files || []).length });
});
app.get('/api/svgs', (_req, res) => {
let files = [];
try { files = fs.readdirSync(SVG_DIR).filter(f => /\.svg$/i.test(f)); } catch (e) {}
res.json(files.sort().map(f => ({ name: f, url: '/svg/' + encodeURIComponent(f) })));
});
// ---- Simple JSON state store (pads / playlist) -----------------------------
app.get('/api/state/:key', (req, res) => {
const f = path.join(STATE_DIR, path.basename(req.params.key) + '.json');
try { res.json(JSON.parse(fs.readFileSync(f, 'utf8'))); }
catch (e) { res.json(null); }
});
app.post('/api/state/:key', (req, res) => {
const f = path.join(STATE_DIR, path.basename(req.params.key) + '.json');
try { fs.writeFileSync(f, JSON.stringify(req.body)); res.json({ ok: true }); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// ---- Waveform peaks (ffmpeg -> mono 8kHz PCM -> N normalized buckets) -------
const peaksCache = new Map();
app.get('/api/peaks', (req, res) => {
const name = path.basename(req.query.file || '');
const buckets = Math.max(40, Math.min(2000, parseInt(req.query.buckets, 10) || 400));
const file = path.join(MEDIA_DIR, name);
const key = name + '|' + buckets;
if (peaksCache.has(key)) return res.json(peaksCache.get(key));
if (!fs.existsSync(file)) return res.json(null);
execFile(ffmpegPath(), ['-v', 'quiet', '-i', file, '-ac', '1', '-ar', '8000', '-f', 's16le', '-'],
{ encoding: 'buffer', maxBuffer: 1 << 28 }, (err, stdout) => {
if (err || !stdout || stdout.length < 4) return res.json(null);
const u8 = Uint8Array.from(stdout);
const s = new Int16Array(u8.buffer, 0, u8.length >> 1);
const n = s.length, per = Math.max(1, Math.floor(n / buckets)), peaks = new Array(buckets).fill(0);
let top = 0.0001;
for (let b = 0; b < buckets; b++) {
let m = 0; const a = b * per, e = Math.min(n, a + per);
for (let i = a; i < e; i++) { const v = s[i] < 0 ? -s[i] : s[i]; if (v > m) m = v; }
peaks[b] = m; if (m > top) top = m;
}
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
peaksCache.set(key, peaks);
res.json(peaks);
});
});
// ---- Recording: output streams WebM chunks, we mux/transcode to MP4 --------
let rec = null; // { ws stream, tmp }
function startRec() {
const tmp = path.join(os.tmpdir(), 'djvpi-rec-' + Date.now() + '.webm');
rec = { tmp, stream: fs.createWriteStream(tmp) };
return true;
}
function transcode(input, output, w, h) {
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
const args = ['-y', '-i', input, '-vf', vf, '-c:v', 'libx264', '-preset', 'veryfast',
'-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', output];
return new Promise((resolve, reject) => {
execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, _o, stderr) =>
err ? reject(new Error(String(stderr || err.message).slice(-400))) : resolve());
});
}
app.post('/api/rec/start', (_req, res) => { startRec(); res.json({ ok: true }); });
app.post('/api/rec/chunk', express.raw({ type: 'application/octet-stream', limit: '64mb' }), (req, res) => {
if (rec && req.body && req.body.length) rec.stream.write(req.body);
res.json({ ok: true });
});
app.post('/api/rec/stop', async (req, res) => {
if (!rec) return res.json({ ok: false, error: 'nessuna registrazione' });
const { w = 1920, h = 1080 } = req.body || {};
const cur = rec; rec = null;
await new Promise(r => cur.stream.end(r));
const out = path.join(REC_DIR, 'DJV-' + Date.now() + '.mp4');
try { await transcode(cur.tmp, out, w, h); fs.unlink(cur.tmp, () => {}); res.json({ ok: true, path: out }); }
catch (e) { res.json({ ok: false, error: e.message }); }
});
// ---- WebSocket relay (the ctl/rpt hub) -------------------------------------
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
let lastEffect = null; // remember last effect so a freshly-opened output syncs
wss.on('connection', (ws, req) => {
ws.role = new URL(req.url, 'http://x').searchParams.get('role') || 'control';
ws.on('message', (data) => {
let msg; try { msg = JSON.parse(data.toString()); } catch (e) { return; }
if (msg.ch === 'ctl') {
if (msg.msg && msg.msg.type === 'effect') lastEffect = msg.msg;
broadcast('output', data); // control -> output(s)
} else if (msg.ch === 'rpt') {
broadcast('control', data); // output -> control(s)
}
});
// Sync a newly-connected output with the current effect.
if (ws.role === 'output' && lastEffect) {
ws.send(JSON.stringify({ ch: 'ctl', msg: lastEffect }));
}
});
function broadcast(role, data) {
wss.clients.forEach(c => { if (c.readyState === 1 && c.role === role) c.send(data); });
}
server.listen(PORT, () => {
const ip = Object.values(os.networkInterfaces()).flat().find(i => i && i.family === 'IPv4' && !i.internal);
console.log('DJ Visualizer Pi server su:');
console.log(' Output (Pi/HDMI): http://localhost:' + PORT + '/output.html');
console.log(' Controllo (phone): http://' + (ip ? ip.address : 'localhost') + ':' + PORT + '/');
console.log(' Media dir: ' + MEDIA_DIR);
});