Files
djaudiovisualizer/main.js
T
lucianoandClaude Fable 5 51c6bca2c8 Recording: 60fps capture, remux when no rescale, CRF 16 slow otherwise
captureStream at 60fps with an 80 Mbps cap. When the requested frame equals
the source and the first pass is H.264, the MP4 is a pure remux (-c copy):
the hardware realtime encode is the only compression in the final file. The
re-encode path (vertical reels, VP9 fallback) moves to CRF 16 preset slow at
constant 60fps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
2026-09-08 17:48:52 +02:00

554 lines
21 KiB
JavaScript

const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell, dialog, protocol, net } = require('electron');
const path = require('path');
const fs = require('fs');
const { execFile } = require('child_process');
const { pathToFileURL } = require('url');
// djvres:// serves the MediaPipe WASM runtime and the bundled pose model to
// the renderer: fetch() does not work on file:// URLs, and the pose tracker
// loads its assets via fetch. Registered before app.ready as required.
protocol.registerSchemesAsPrivileged([{ scheme: 'djvres',
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true } }]);
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;
}
let quitting = false;
function createOutputWindow() {
const primary = screen.getPrimaryDisplay();
const ext = externalDisplay();
const hasExternal = ext.id !== primary.id;
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));
}
// The output window hosts the audio engine and all visual state: closing it
// by accident (Cmd+W, red button) would kill the show. Ignore close requests
// while the control panel is open; only app quit may close it.
outputWin.on('close', (e) => {
if (!quitting && controlWin && !controlWin.isDestroyed()) e.preventDefault();
});
// Safety net: if it dies anyway (e.g. a renderer crash), bring it back.
outputWin.on('closed', () => {
outputWin = null;
if (!quitting && controlWin && !controlWin.isDestroyed()) createOutputWindow();
});
outputWin.webContents.on('render-process-gone', () => {
if (quitting) return;
if (outputWin && !outputWin.isDestroyed()) outputWin.destroy();
});
}
// --- Aux output windows (true multi-output: one extra window per display) ---
// Keyed by display id. Each loads aux.html: a lightweight visual-only renderer
// (own effect / follow the main effect / image / black) fed with the audio
// analysis relayed from the main output at ~30 Hz on the 'afr' channel.
const auxWins = new Map();
function tellOutputAuxActive() {
if (outputWin && !outputWin.isDestroyed()) {
outputWin.webContents.send('ctl', { type: 'auxActive', on: auxWins.size > 0 });
}
}
function createAuxWindow(display) {
const win = new BrowserWindow({
x: display.bounds.x, y: display.bounds.y,
width: display.bounds.width, height: display.bounds.height,
title: 'DJ Visualizer — Uscita ' + display.id,
backgroundColor: '#000000',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
additionalArguments: ['--role=aux', '--display=' + display.id]
}
});
win.loadFile(path.join(__dirname, 'src', 'aux.html'));
win.once('ready-to-show', () => {
win.setBounds({ ...display.bounds });
if (process.platform === 'darwin') win.setSimpleFullScreen(true);
else win.setFullScreen(true);
});
win.on('closed', () => {
if (auxWins.get(display.id) === win) auxWins.delete(display.id);
tellOutputAuxActive();
if (controlWin && !controlWin.isDestroyed()) {
controlWin.webContents.send('rpt', { type: 'auxClosed', displayId: display.id });
}
});
auxWins.set(display.id, win);
tellOutputAuxActive();
return win;
}
// Reconcile the aux window set with the wanted list of display ids.
ipcMain.handle('aux:sync', (_e, wantedIds) => {
const wanted = new Set(wantedIds || []);
for (const [id, win] of [...auxWins]) {
if (!wanted.has(id)) { auxWins.delete(id); if (!win.isDestroyed()) win.destroy(); }
}
tellOutputAuxActive();
const displays = screen.getAllDisplays();
for (const id of wanted) {
if (auxWins.has(id)) continue;
const d = displays.find(x => x.id === id);
if (d) createAuxWindow(d);
}
return [...auxWins.keys()];
});
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: 980,
height: 1180,
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) ---
createOutputWindow();
controlWin.on('closed', () => {
quitting = true; // closing the panel ends the show: let the output close
controlWin = null;
for (const win of auxWins.values()) { if (!win.isDestroyed()) win.destroy(); }
auxWins.clear();
if (outputWin) outputWin.close();
app.quit();
});
}
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 */ }
}
// Convert a Mixamo FBX animation into a skeleton-only GLB clip via Blender
// (when installed): lets the user add moves to the dance library from the
// panel without any manual conversion step.
function blenderPath() {
const cands = ['/Applications/Blender.app/Contents/MacOS/Blender',
'/opt/homebrew/bin/blender', '/usr/local/bin/blender'];
if (process.platform === 'win32') {
// Blender installs versioned folders under Program Files
for (const root of ['C:\\Program Files\\Blender Foundation', 'C:\\Program Files (x86)\\Blender Foundation']) {
try {
for (const d of fs.readdirSync(root))
cands.push(path.join(root, d, 'blender.exe'));
} catch (e) { /* root missing */ }
}
}
for (const c of cands) if (fs.existsSync(c)) return c;
return null;
}
function convertFbx(fbxPath, scriptName, outSub) {
const blender = blenderPath();
if (!blender) return Promise.resolve({ ok: false,
error: 'Blender non trovato: serve per convertire gli FBX (oppure carica un .glb)' });
const outDir = path.join(app.getPath('userData'), outSub);
fs.mkdirSync(outDir, { recursive: true });
const out = path.join(outDir, path.basename(fbxPath).replace(/\.fbx$/i, '') + '.glb');
// packaged app: the .py must live outside the asar for Blender to read it
const script = path.join(__dirname, 'scripts', scriptName)
.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
return new Promise((resolve) => {
execFile(blender, ['-b', '-P', script, '--', fbxPath, out],
{ maxBuffer: 1 << 24, timeout: 300000 }, (err, _so, se) => {
if (err || !fs.existsSync(out))
resolve({ ok: false, error: 'conversione fallita: ' + String(se || (err && err.message) || '').slice(-300) });
else resolve({ ok: true, path: out });
});
});
}
ipcMain.handle('anim:convert', (_e, p) => convertFbx(p, 'fbx2animglb.py', 'anims'));
ipcMain.handle('model:convert', (_e, p) => convertFbx(p, 'fbx2modelglb.py', 'models3d'));
// Camera permission is requested lazily (only when an interactive preset is
// activated), not at startup: no camera prompt for users who never use it.
ipcMain.handle('cam:ensure', async () => {
if (process.platform !== 'darwin') return true;
try {
const status = systemPreferences.getMediaAccessStatus('camera');
if (status !== 'granted') return await systemPreferences.askForMediaAccess('camera');
return true;
} catch (e) { return true; }
});
app.on('before-quit', () => { quitting = true; });
app.whenReady().then(async () => {
// djvres://mp/<file> -> node_modules/@mediapipe/tasks-vision/<file>
// djvres://models/<file> -> models/<file>
const RES_ROOTS = {
mp: path.join(__dirname, 'node_modules', '@mediapipe', 'tasks-vision'),
models: path.join(__dirname, 'models')
};
protocol.handle('djvres', async (req) => {
try {
const u = new URL(req.url);
const root = RES_ROOTS[u.host];
const rel = decodeURIComponent(u.pathname).replace(/^\//, '');
if (!root || rel.includes('..')) return new Response('forbidden', { status: 403 });
const res = await net.fetch(pathToFileURL(path.join(root, rel)).toString());
const headers = new Headers();
headers.set('Access-Control-Allow-Origin', '*');
if (rel.endsWith('.wasm')) headers.set('Content-Type', 'application/wasm');
else if (rel.endsWith('.js') || rel.endsWith('.mjs')) headers.set('Content-Type', 'text/javascript');
return new Response(res.body, { status: res.status, headers });
} catch (e) {
return new Response('not found', { status: 404 });
}
});
if (process.platform === 'darwin' && app.dock) {
try { app.dock.setIcon(path.join(__dirname, 'build', 'icon_1024.png')); } catch (e) { /* non-fatal */ }
}
await ensureMicAccess();
createWindows();
screen.on('display-added', displaysChanged);
screen.on('display-removed', displaysChanged);
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);
// Aux outputs listen to the same channel (they pick what they care about:
// their own 'auxCfg' and the broadcast 'effect' for follow mode).
for (const win of auxWins.values()) {
if (!win.isDestroyed()) win.webContents.send('ctl', msg);
}
});
// Audio analysis frames: main output -> every aux output (never the control).
ipcMain.on('afr', (_e, data) => {
for (const win of auxWins.values()) {
if (!win.isDestroyed()) win.webContents.send('afr', data);
}
});
// Output -> Control (reports: meters, device list, play state).
ipcMain.on('rpt', (_e, msg) => {
if (controlWin && !controlWin.isDestroyed()) controlWin.webContents.send('rpt', msg);
});
// --- Display management --------------------------------------------------
// --- Recording: receive WebM chunks, mux to MP4 at a chosen aspect ratio ---
let recStream = null, recTempPath = null;
function ffmpegPath() {
const cands = [];
try { cands.push(require('ffmpeg-static')); } catch (e) { /* optional */ }
// Absolute candidates FIRST: the packaged app launched from Finder has a
// minimal PATH (no /opt/homebrew/bin), so a bare 'ffmpeg' spawns ENOENT
// there even though the same call works when started from a terminal.
cands.push(process.env.FFMPEG,
'/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg',
'C:\\ffmpeg\\bin\\ffmpeg.exe', 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe');
for (const c of cands) {
if (c && fs.existsSync(c)) return c;
}
return 'ffmpeg'; // last resort: hope PATH has it
}
function transcodeToMp4(input, output, opts) {
const w = (opts && opts.w) || 1920;
const h = (opts && opts.h) || 1080;
// Cover the target frame then centre-crop, so the chosen aspect is filled
// without distortion.
const args = ['-y', '-i', input];
if (opts && opts.srcH264 && opts.srcW === w && opts.srcH === h) {
// Source already matches the requested frame and is H.264: REMUX without
// re-encoding — the hardware realtime pass stays the only encode in the
// final file (zero generational loss).
args.push('-c', 'copy');
} else {
// Rescale/crop needed (e.g. vertical reels) or non-H264 fallback source.
// Post-processing, not realtime: spend encode time on quality. -r 60
// turns the recorder's variable frame timing into constant fps.
const vf = `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`;
args.push('-vf', vf, '-r', '60',
'-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '256k');
}
args.push('-movflags', '+faststart', output);
return new Promise((resolve, reject) => {
execFile(ffmpegPath(), args, { maxBuffer: 1 << 24 }, (err, stdout, stderr) => {
if (err) reject(new Error('ffmpeg: ' + String(stderr || err.message).slice(-500)));
else resolve();
});
});
}
// Read raw file bytes for the renderer (used to decode waveforms via Web Audio,
// which needs no external ffmpeg). Returns null if unreadable.
ipcMain.handle('file:read', async (_e, filePath) => {
try { return await fs.promises.readFile(filePath); }
catch (e) { return null; }
});
// Decode a media file to mono 8 kHz PCM via ffmpeg and reduce it to `buckets`
// peak amplitudes (0..1) for drawing a waveform. Returns null on failure.
const peaksCache = new Map();
ipcMain.handle('audio:peaks', async (_e, filePath, buckets) => {
buckets = buckets || 400;
const key = filePath + '|' + buckets;
if (peaksCache.has(key)) return peaksCache.get(key);
const args = ['-v', 'quiet', '-i', filePath, '-ac', '1', '-ar', '8000', '-f', 's16le', '-'];
return new Promise((resolve) => {
execFile(ffmpegPath(), args, { encoding: 'buffer', maxBuffer: 1 << 28 }, (err, stdout) => {
if (err || !stdout || stdout.length < 4) { resolve(null); return; }
const u8 = Uint8Array.from(stdout);
const samples = new Int16Array(u8.buffer, 0, u8.length >> 1);
const n = samples.length;
const peaks = new Array(buckets).fill(0);
const per = Math.max(1, Math.floor(n / buckets));
for (let b = 0; b < buckets; b++) {
let max = 0;
const s = b * per, e = Math.min(n, s + per);
for (let i = s; i < e; i++) { const v = samples[i] < 0 ? -samples[i] : samples[i]; if (v > max) max = v; }
peaks[b] = max / 32768;
}
// Normalize so the loudest peak fills the height.
const top = Math.max(0.0001, ...peaks);
for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top);
peaksCache.set(key, peaks);
resolve(peaks);
});
});
});
function recordingsDir() {
const dir = path.join(app.getPath('videos'), 'DJ Visualizer');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
ipcMain.handle('rec:start', () => {
recTempPath = path.join(app.getPath('temp'), 'djv-rec-' + Date.now() + '.webm');
recStream = fs.createWriteStream(recTempPath);
return true;
});
ipcMain.on('rec:chunk', (_e, bytes) => {
if (recStream && bytes) recStream.write(Buffer.from(bytes));
});
ipcMain.handle('rec:stop', async (_e, opts) => {
if (!recStream) return { ok: false, error: 'Nessuna registrazione attiva' };
await new Promise(r => recStream.end(r));
const temp = recTempPath;
recStream = null; recTempPath = null;
try {
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const outPath = path.join(recordingsDir(), 'rec-' + stamp + '.mp4');
await transcodeToMp4(temp, outPath, opts);
try { fs.unlinkSync(temp); } catch (e) { /* ignore */ }
return { ok: true, path: outPath };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('rec:openFolder', () => {
const dir = recordingsDir();
shell.openPath(dir);
return dir;
});
// --- Pad bank persistence ---
const padsFile = () => path.join(app.getPath('userData'), 'pads.json');
ipcMain.handle('pads:load', () => {
try { return JSON.parse(fs.readFileSync(padsFile(), 'utf8')); } catch (e) { return null; }
});
ipcMain.handle('pads:save', (_e, data) => {
try { fs.writeFileSync(padsFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
});
const playlistFile = () => path.join(app.getPath('userData'), 'playlist.json');
ipcMain.handle('playlist:load', () => {
try { return JSON.parse(fs.readFileSync(playlistFile(), 'utf8')); } catch (e) { return null; }
});
ipcMain.handle('playlist:save', (_e, data) => {
try { fs.writeFileSync(playlistFile(), JSON.stringify(data)); return true; } catch (e) { return false; }
});
// Export/import a playlist to a user-chosen file.
ipcMain.handle('playlist:export', async (_e, data) => {
const res = await dialog.showSaveDialog(controlWin, {
title: 'Salva playlist',
defaultPath: 'set.djvplaylist.json',
filters: [{ name: 'Playlist DJ Visualizer', extensions: ['json'] }]
});
if (res.canceled || !res.filePath) return { ok: false };
try { fs.writeFileSync(res.filePath, JSON.stringify(data, null, 2)); return { ok: true, path: res.filePath }; }
catch (e) { return { ok: false, error: e.message }; }
});
ipcMain.handle('playlist:import', async () => {
const res = await dialog.showOpenDialog(controlWin, {
title: 'Carica playlist',
properties: ['openFile'],
filters: [{ name: 'Playlist', extensions: ['json'] }]
});
if (res.canceled || !res.filePaths.length) return null;
try { return JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8')); } catch (e) { return null; }
});
// Read the bundled svg/ folder and return each SVG as a data: URL.
ipcMain.handle('svg:listBuiltin', () => {
try {
const dir = path.join(__dirname, 'svg');
return fs.readdirSync(dir)
.filter(f => f.toLowerCase().endsWith('.svg'))
.map(f => {
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
const dataUrl = 'data:image/svg+xml;base64,' + Buffer.from(txt, 'utf8').toString('base64');
const name = f
.replace(/\.svg$/i, '')
.replace(/-svgrepo-com.*$/i, '')
.replace(/^freesvg[\s-]+/i, '')
.replace(/[-_]+/g, ' ')
.trim();
return { name: name || f, dataUrl };
});
} catch (e) { return []; }
});
function outputDisplayId() {
if (!outputWin || outputWin.isDestroyed()) return -1;
try {
const b = outputWin.getBounds();
return screen.getDisplayNearestPoint({
x: b.x + Math.floor(b.width / 2), y: b.y + Math.floor(b.height / 2)
}).id;
} catch (e) { return -1; }
}
ipcMain.handle('displays:list', () => {
const primary = screen.getPrimaryDisplay();
const outId = outputDisplayId();
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,
hasOutput: d.id === outId,
hasAux: auxWins.has(d.id)
}));
});
// Hot-plug: keep the panel's display lists fresh and drop aux windows whose
// display disappeared (their fullscreen window would otherwise pile up on
// another screen).
function displaysChanged() {
const ids = new Set(screen.getAllDisplays().map(d => d.id));
for (const [id, win] of [...auxWins]) {
if (!ids.has(id)) { auxWins.delete(id); if (!win.isDestroyed()) win.destroy(); }
}
tellOutputAuxActive();
if (controlWin && !controlWin.isDestroyed()) {
controlWin.webContents.send('rpt', { type: 'displaysChanged' });
}
}
// 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();
});