diff --git a/main.js b/main.js index c81e7e7..dac5f1b 100644 --- a/main.js +++ b/main.js @@ -134,6 +134,37 @@ function transcodeToMp4(input, output, opts) { }); } +// 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 }); diff --git a/preload.js b/preload.js index 7cbc132..328e90c 100644 --- a/preload.js +++ b/preload.js @@ -34,5 +34,6 @@ contextBridge.exposeInMainWorld('djv', { loadPlaylist: () => ipcRenderer.invoke('playlist:load'), savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data), exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data), - importPlaylist: () => ipcRenderer.invoke('playlist:import') + importPlaylist: () => ipcRenderer.invoke('playlist:import'), + peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets) }); diff --git a/src/audio.js b/src/audio.js index f37a691..b18317e 100644 --- a/src/audio.js +++ b/src/audio.js @@ -34,6 +34,9 @@ class AudioEngine { this.stream = null; // MediaStream when using live input this.mode = 'none'; this.onEnded = null; // callback fired when a (non-looping) track ends + this.trimStart = 0; // playback start point (s); seek here on load + this.trimEnd = 0; // playback end point (s); 0 = play to the natural end + this._trimFired = false; this._videoSrc = null; // persistent source for the playlist video element this._videoGain = null; this._videoSrcEl = null; @@ -68,6 +71,28 @@ class AudioEngine { resume() { if (this.ctx.state === 'suspended') this.ctx.resume(); } + // Trim the current/next track to [start, end] seconds (end 0 = natural end). + setTrim(start, end) { + this.trimStart = Math.max(0, start || 0); + this.trimEnd = Math.max(0, end || 0); + this._trimFired = false; + } + // Seek a freshly-loaded media element to the trim start (waits for metadata). + seekToTrimStart(el) { + const go = () => { if (this.trimStart > 0) { try { el.currentTime = this.trimStart; } catch (e) {} } }; + if (el.readyState >= 1) go(); else el.addEventListener('loadedmetadata', go, { once: true }); + } + // Called every frame from the output loop: stop at the trim end point. + checkTrim() { + const el = this.mediaEl; + if (!el || el.paused || this._trimFired) return; + if (this.trimEnd > 0 && el.currentTime >= this.trimEnd) { + this._trimFired = true; + try { el.pause(); } catch (e) {} + if (this.onEnded) this.onEnded(); + } + } + // Stop any current audio/video source but keep the context & analyser alive // (meters read 0, recording keep-alive continues). Used for visual-only // playlist interludes. @@ -128,8 +153,10 @@ class AudioEngine { this.sourceGain = g; this.stream = null; this.mode = 'file'; + this._trimFired = false; await el.play(); + this.seekToTrimStart(el); return el; } diff --git a/src/control.js b/src/control.js index 8b415c3..96565b9 100644 --- a/src/control.js +++ b/src/control.js @@ -202,7 +202,106 @@ let isPlaying = false; // whether the current track is playing let ended = false; // true when the current track reached its end let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback const durations = {}; // path -> seconds (probed from the output window) +const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'loading') +const WAVE_BUCKETS = 400; let playCur = 0, playDur = 0; // live progress of the currently playing track + +// Lazily fetch the waveform peaks for a file, then redraw its card. +function ensurePeaks(path) { + if (!path || peaksCache[path]) return; + peaksCache[path] = 'loading'; + djv.peaks(path, WAVE_BUCKETS).then((p) => { + peaksCache[path] = p && p.length ? p : null; + renderPlaylist(); + }).catch(() => { peaksCache[path] = null; }); +} + +// Draw a waveform on a canvas, shading the trimmed-out parts and the playhead. +function drawWave(canvas, tr, isCur) { + const peaks = peaksCache[tr.path]; + const ctx = canvas.getContext('2d'); + const w = canvas.width, h = canvas.height, mid = h / 2; + ctx.clearRect(0, 0, w, h); + if (!Array.isArray(peaks)) { // still loading / unavailable + ctx.fillStyle = 'rgba(255,255,255,0.06)'; + ctx.fillRect(0, mid - 1, w, 2); + return; + } + const dur = durations[tr.path] || 0; + const s = tr.start || 0; + const e = tr.end > 0 ? tr.end : dur; + const sx = dur > 0 ? (s / dur) * w : 0; + const ex = dur > 0 ? (e / dur) * w : w; + const N = peaks.length; + for (let x = 0; x < w; x++) { + const pk = peaks[Math.min(N - 1, Math.floor(x / w * N))]; + const bar = Math.max(1, pk * (h * 0.46)); + const inTrim = x >= sx && x <= ex; + ctx.fillStyle = inTrim ? 'rgba(140,182,255,0.85)' : 'rgba(140,182,255,0.18)'; + ctx.fillRect(x, mid - bar, 1, bar * 2); + } + // trim handles + if (dur > 0) { + ctx.fillStyle = '#6ee7a0'; ctx.fillRect(sx, 0, 2, h); // start (green) + ctx.fillStyle = '#ff9a9a'; ctx.fillRect(ex - 2, 0, 2, h); // end (red) + } + // playhead + if (isCur && playDur > 0) { + const px = Math.min(w, (playCur / playDur) * w); + ctx.fillStyle = '#ffffff'; ctx.fillRect(px, 0, 1.5, h); + } +} + +// Clamp and persist a track's start/end; push it live to the output if playing. +function commitTrim(tr, i, rerender) { + const dur = durations[tr.path] || 0; + tr.start = Math.max(0, tr.start || 0); + if (dur > 0 && tr.start > dur) tr.start = dur; + if (tr.end > 0) { + if (dur > 0 && tr.end > dur) tr.end = dur; + if (tr.end <= tr.start + 0.2) tr.end = Math.min(dur || tr.start + 1, tr.start + 1); + } + savePlaylistState(); + if (i === currentIndex) send({ type: 'setTrim', start: tr.start || 0, end: tr.end || 0 }); + if (rerender) renderPlaylist(); +} + +// Wire the trim inputs, the "set to current" buttons and waveform drag handles. +function wireTrim(li, canvas, tr, i) { + const sIn = li.querySelector('.trim-start'); + const eIn = li.querySelector('.trim-end'); + sIn.addEventListener('change', () => { tr.start = parseTime(sIn.value); commitTrim(tr, i, true); }); + eIn.addEventListener('change', () => { tr.end = eIn.value.trim() ? parseTime(eIn.value) : 0; commitTrim(tr, i, true); }); + li.querySelector('.trim-here-s').addEventListener('click', () => { if (i === currentIndex) { tr.start = Math.max(0, playCur); commitTrim(tr, i, true); } }); + li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } }); + li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); }); + + let dragH = null; + const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0; + const timeAt = (e) => { + const rect = canvas.getBoundingClientRect(); + const f = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + return f * dur(); + }; + canvas.addEventListener('pointerdown', (e) => { + if (!dur()) return; + const t = timeAt(e), s = tr.start || 0, en = tr.end > 0 ? tr.end : dur(); + dragH = Math.abs(t - s) <= Math.abs(t - en) ? 'start' : 'end'; + canvas.setPointerCapture(e.pointerId); e.preventDefault(); + }); + canvas.addEventListener('pointermove', (e) => { + if (!dragH) return; + const t = timeAt(e); + if (dragH === 'start') tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5)); + else tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5)); + drawWave(canvas, tr, i === currentIndex); + sIn.value = fmtTime(Math.floor(tr.start || 0)); + eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : ''; + }); + const endDrag = () => { if (dragH) { dragH = null; commitTrim(tr, i, true); } }; + canvas.addEventListener('pointerup', endDrag); + canvas.addEventListener('pointercancel', endDrag); +} let padCrossfadeMs = 0; // crossfade duration for pad triggers let capturingFor = -1; // index of the track awaiting a hotkey, or -1 let sceneEditing = -1; // playlist index whose scene editor is open @@ -210,10 +309,10 @@ let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image let activeCues = [], firedCue = -1; // cue schedule for the playing track function serializePlaylist() { - return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0 })); + return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 })); } function normalizeTrack(t) { - return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0 }; + return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 }; } function savePlaylistState() { if (djv.savePlaylist) djv.savePlaylist(serializePlaylist()); @@ -362,7 +461,7 @@ function probePaths(paths) { function addTracks(items) { // items: array of { path, name } const start = playlist.length; - items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0 }); }); + items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0, start: 0, end: 0 }); }); renderPlaylist(); savePlaylistState(); probePaths(items.map(it => it.path)); @@ -393,7 +492,7 @@ function playIndex(i) { playInterlude(tr); return; } - send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path }); + send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: tr.start || 0, end: tr.end || 0 }); startCues(tr); renderPlaylist(); } @@ -668,16 +767,46 @@ function renderPlaylist() { const prog = (isCur && playDur > 0) ? Math.min(100, playCur / playDur * 100) : 0; const gapBadge = (tr.gap > 0 && !tr.isInterlude) ? '⏸' + tr.gap + 's' : ''; const icon = tr.isInterlude ? '✨ ' : (tr.isVideo ? '🎞 ' : ''); + + // Total / elapsed / remaining for the trimmed range. + const dur = durations[tr.path] || (isCur ? playDur : 0); + const tStart = tr.start || 0; + const tEnd = tr.end > 0 ? tr.end : dur; + const total = Math.max(0, tEnd - tStart); + const elapsed = isCur ? Math.max(0, Math.min(total, playCur - tStart)) : 0; + const remaining = Math.max(0, total - elapsed); + + const body = (tr.isInterlude || !tr.path) ? '' : + '