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) ? '' : + '
' + + '
' + + '▶ ' + fmtTime(Math.floor(elapsed)) + '' + + '⏱ ' + fmtTime(Math.floor(total)) + '' + + '⧗ -' + fmtTime(Math.ceil(remaining)) + '' + + '
' + + '' + + '
' + + 'Inizio' + + '' + + '' + + 'Fine' + + '' + + '' + + '' + + '
' + + '
'; + li.innerHTML = - '⠿' + - '' + icon + tr.name + gapBadge + '' + - '' + timeLabel + '' + - '' + - '' + - '' + - '' + - (isCur ? '' : ''); + '
' + + '⠿' + + '' + icon + tr.name + gapBadge + '' + + '' + timeLabel + '' + + '' + + '' + + '' + + '' + + (isCur ? '' : '') + + '
' + body; // Clicking the name (re)starts the track from the beginning. li.querySelector('.tname').addEventListener('click', () => playIndex(i)); @@ -696,6 +825,14 @@ function renderPlaylist() { renderPlaylist(); }); + // Waveform + trim controls. + const canvas = li.querySelector('.wave'); + if (canvas) { + ensurePeaks(tr.path); + drawWave(canvas, tr, isCur); + wireTrim(li, canvas, tr, i); + } + // Drag to reorder. li.addEventListener('dragstart', () => { dragFrom = i; li.classList.add('dragging'); }); li.addEventListener('dragend', () => { dragFrom = -1; li.classList.remove('dragging'); }); diff --git a/src/output.js b/src/output.js index 56da96b..de74aa7 100644 --- a/src/output.js +++ b/src/output.js @@ -323,6 +323,7 @@ djv.onControl(async (m) => { try { // Switching to an audio track: stop/hide any playing video track. trackVideo.pause(); trackVideo.classList.remove('show'); + audio.setTrim(m.start || 0, m.end || 0); audio.onEnded = () => djv.report({ type: 'trackEnded' }); await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 }); hideHint(); @@ -331,6 +332,7 @@ djv.onControl(async (m) => { break; case 'playVideoTrack': try { + audio.setTrim(m.start || 0, m.end || 0); audio.onEnded = () => djv.report({ type: 'trackEnded' }); audio.attachVideo(trackVideo); trackVideo.loop = false; trackVideo.muted = false; @@ -341,6 +343,7 @@ djv.onControl(async (m) => { trackVideo.load(); trackVideo.classList.add('show'); await trackVideo.play(); + audio.seekToTrimStart(trackVideo); hideHint(); djv.report({ type: 'playState', playing: true }); } catch (e) { djv.report({ type: 'error', message: e.message }); } @@ -355,6 +358,7 @@ djv.onControl(async (m) => { djv.report({ type: 'playState', playing: true }); } catch (e) { djv.report({ type: 'error', message: e.message }); } break; + case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break; case 'probeDurations': probeDurations(m.paths || []); break; case 'togglePlay': { const playing = audio.togglePlay(); @@ -470,6 +474,8 @@ function frame() { lastReport = now; djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps }); } + // Stop at the trim end point if one is set. + audio.checkTrim(); // Playback progress for the currently playing file. if (now - lastProgress > 400) { lastProgress = now; diff --git a/src/style.css b/src/style.css index 9ea865f..7586ff1 100644 --- a/src/style.css +++ b/src/style.css @@ -294,16 +294,23 @@ textarea.textarea-lg { .track { position: relative; display: flex; - align-items: center; - gap: 6px; - padding: 6px 8px; - margin-bottom: 5px; + flex-direction: column; + align-items: stretch; + gap: 0; + margin-bottom: 6px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 9px; font-size: 12px; overflow: hidden; } +.track .track-head { + position: relative; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; +} .track .ttime { flex: 0 0 auto; font-size: 10px; @@ -319,6 +326,28 @@ textarea.textarea-lg { background: linear-gradient(90deg, #5ad1ff, #7aa2ff, #ff7ad1); width: 0; } +.track .track-body { padding: 0 8px 8px; } +.track .track-times { + display: flex; gap: 10px; align-items: center; + font-size: 10px; font-variant-numeric: tabular-nums; margin-bottom: 5px; +} +.track .track-times .tt-el { color: #9be6a0; } +.track .track-times .tt-tot { opacity: 0.7; } +.track .track-times .tt-rem { color: #ffb3b3; margin-left: auto; } +.track .wave { + display: block; width: 100%; height: 46px; + background: rgba(0,0,0,0.28); border-radius: 6px; cursor: ew-resize; + touch-action: none; +} +.track .track-trim { + display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 10px; flex-wrap: wrap; +} +.track .track-trim .trim-lbl { opacity: 0.6; } +.track .track-trim .trim-lbl-s { color: #9be6a0; opacity: 0.9; } +.track .track-trim .trim-lbl-e { color: #ffb3b3; opacity: 0.9; } +.track .track-trim input { width: 52px; text-align: center; padding: 4px 3px; font-variant-numeric: tabular-nums; } +.track .track-trim button { padding: 3px 6px; } +.track .track-trim .trim-reset { margin-left: auto; } .track.playing { background: rgba(110,150,255,0.22); border-color: rgba(120,170,255,0.6);