diff --git a/README.md b/README.md index e5bfedc..dcf9273 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # 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**. +> di **Dj LuZa** · info@djluza.com + +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** (con **playlist** riordinabile e tasti rapidi), 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**. + +Include un **motore di effetti parametrico**: 16 famiglie di shader × palette di colori × varianti di stile generano **oltre 1500 preset**, con una **sequenza di effetti** (playlist) ad auto-cambio a tempo o sul beat. ## Due finestre @@ -22,17 +26,26 @@ npm start | 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 | +| `1`–`9` | Applica i primi 9 effetti della sequenza | +| `F` | Schermo intero output | +| `O` | Aggiungi brani alla playlist | | `G` | Carica immagini | | `T` | Mostra/nascondi testo scorrevole | | `← →` | Cambia immagine | -| `Spazio` | Play/pausa del file | +| `Spazio` | Play/pausa del brano | +| tasti personalizzati | Avvio rapido di brani e effetti (assegnabili dal pannello con ⌨) | -Puoi anche **trascinare** un brano (mp3/wav) o delle immagini direttamente sulla finestra. +Puoi anche **trascinare** brani (mp3/wav) o immagini direttamente sul pannello. + +## Effetti + +- **Libreria effetti**: oltre 1500 preset filtrabili per famiglia e ricercabili per nome. Clic per applicarli all'istante. +- **Sequenza effetti**: aggiungi i preset preferiti (➕), riordinali col drag, assegna tasti rapidi, e attiva **Auto-cambio** (a intervallo) o **Sul beat** (cambia a ritmo), con **Shuffle**. +- **Equalizzatore visual**: regola quanto Bassi/Medi/Alti influenzano la grafica. + +## Playlist audio + +Carica più brani, riordinali col drag della maniglia ⠿, assegna un tasto rapido a ciascuno (⌨), avvia/pausa per brano (▶/⏸) e attiva **Ripeti** per l'avanzamento automatico a fine traccia. ## Schermo esterno diff --git a/build/gen-icon.js b/build/gen-icon.js new file mode 100644 index 0000000..90bfbaa --- /dev/null +++ b/build/gen-icon.js @@ -0,0 +1,21 @@ +// Renders build/icon-source.html offscreen and writes build/icon_1024.png. +const { app, BrowserWindow } = require('electron'); +const path = require('path'); +const fs = require('fs'); + +app.whenReady().then(() => { + const win = new BrowserWindow({ width: 1024, height: 1024, show: false, + webPreferences: { offscreen: false } }); + win.loadFile(path.join(__dirname, 'icon-source.html')); + win.webContents.once('did-finish-load', () => { + setTimeout(async () => { + const dataUrl = await win.webContents.executeJavaScript( + 'document.getElementById("c").toDataURL("image/png")'); + const b64 = dataUrl.replace(/^data:image\/png;base64,/, ''); + fs.writeFileSync(path.join(__dirname, 'icon_1024.png'), Buffer.from(b64, 'base64')); + console.log('WROTE icon_1024.png'); + app.quit(); + }, 400); + }); +}); +app.on('window-all-closed', () => app.quit()); diff --git a/build/icon-source.html b/build/icon-source.html new file mode 100644 index 0000000..1dda2af --- /dev/null +++ b/build/icon-source.html @@ -0,0 +1,112 @@ + + + + + + + + diff --git a/build/icon.icns b/build/icon.icns new file mode 100644 index 0000000..4171df0 Binary files /dev/null and b/build/icon.icns differ diff --git a/build/icon_1024.png b/build/icon_1024.png new file mode 100644 index 0000000..1a1a45c Binary files /dev/null and b/build/icon_1024.png differ diff --git a/main.js b/main.js index bc5fd9d..2c5d89c 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,7 @@ -const { app, BrowserWindow, screen, ipcMain, systemPreferences } = require('electron'); +const { app, BrowserWindow, screen, ipcMain, systemPreferences, shell } = require('electron'); const path = require('path'); +const fs = require('fs'); +const { execFile } = require('child_process'); let controlWin = null; // panel with all the controls (primary screen) let outputWin = null; // pure visualization (external screen, fullscreen) @@ -74,6 +76,9 @@ async function ensureMicAccess() { } app.whenReady().then(async () => { + if (process.platform === 'darwin' && app.dock) { + try { app.dock.setIcon(path.join(__dirname, 'build', 'icon_1024.png')); } catch (e) { /* non-fatal */ } + } await ensureMicAccess(); createWindows(); app.on('activate', () => { @@ -97,6 +102,105 @@ ipcMain.on('rpt', (_e, 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 */ } + cands.push(process.env.FFMPEG, 'ffmpeg', + '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg'); + for (const c of cands) { + if (!c) continue; + if (c === 'ffmpeg' || fs.existsSync(c)) return c; + } + return 'ffmpeg'; +} + +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 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, stdout, stderr) => { + if (err) reject(new Error('ffmpeg: ' + String(stderr || err.message).slice(-500))); + else resolve(); + }); + }); +} + +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; } +}); + +// 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 []; } +}); + ipcMain.handle('displays:list', () => { const primary = screen.getPrimaryDisplay(); return screen.getAllDisplays().map((d, i) => ({ diff --git a/package.json b/package.json index df99ab0..82f83dc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "version": "1.0.0", "description": "Audio-reactive fractal & abstract visualizer for external display (Mac)", "main": "main.js", - "author": "", + "author": { + "name": "Dj LuZa", + "email": "info@djluza.com" + }, "license": "MIT", "scripts": { "start": "electron .", @@ -12,14 +15,17 @@ "build": { "appId": "com.djvisualizer.app", "productName": "DJ Visualizer", + "copyright": "© Dj LuZa", "mac": { "category": "public.app-category.music", - "target": "dmg" + "target": "dmg", + "icon": "build/icon.icns" }, "files": [ "main.js", "preload.js", - "src/**/*" + "src/**/*", + "svg/**/*" ] }, "devDependencies": { diff --git a/preload.js b/preload.js index 7a499cf..fa7634c 100644 --- a/preload.js +++ b/preload.js @@ -13,6 +13,8 @@ contextBridge.exposeInMainWorld('djv', { report: (msg) => ipcRenderer.send('rpt', msg), onReport: (cb) => ipcRenderer.on('rpt', (_e, m) => cb(m)), // Display management (control window). + // Bundled svg/ folder, read in the main process, as same-origin data URLs. + listBuiltinSvgs: () => ipcRenderer.invoke('svg:listBuiltin'), // Resolve a dropped/picked File to its absolute filesystem path. pathForFile: (file) => { try { return webUtils.getPathForFile(file); } @@ -20,5 +22,13 @@ contextBridge.exposeInMainWorld('djv', { }, listDisplays: () => ipcRenderer.invoke('displays:list'), moveOutputTo: (id) => ipcRenderer.invoke('output:moveTo', id), - toggleOutputFullscreen: () => ipcRenderer.invoke('output:toggleFullscreen') + toggleOutputFullscreen: () => ipcRenderer.invoke('output:toggleFullscreen'), + // Recording (output streams chunks to main, which muxes/transcodes to MP4). + recStart: () => ipcRenderer.invoke('rec:start'), + recChunk: (bytes) => ipcRenderer.send('rec:chunk', bytes), + recStop: (opts) => ipcRenderer.invoke('rec:stop', opts), + openRecordingsFolder: () => ipcRenderer.invoke('rec:openFolder'), + // Pad bank persistence. + loadPads: () => ipcRenderer.invoke('pads:load'), + savePads: (data) => ipcRenderer.invoke('pads:save', data) }); diff --git a/src/audio.js b/src/audio.js index 75decce..c5e8792 100644 --- a/src/audio.js +++ b/src/audio.js @@ -16,17 +16,37 @@ class AudioEngine { this.outGain.gain.value = 1.0; this.outGain.connect(this.ctx.destination); + // Tap for recording: every source also feeds this destination so the + // recorder captures file playback and live input alike. + this.recordDest = this.ctx.createMediaStreamDestination(); + // Silent keep-alive so the audio track always produces frames (otherwise, + // with no source connected, the recorder stalls with empty data). + try { + const keep = this.ctx.createConstantSource(); + keep.offset.value = 0; + keep.connect(this.recordDest); + keep.start(); + } catch (e) { /* ConstantSource unsupported: live/file audio still flows */ } + this.sourceNode = null; // current MediaElement / MediaStream source this.mediaEl = null; //