From 0c2f2b8190e26fc87d1805fb4ba962f18e4dadd3 Mon Sep 17 00:00:00 2001 From: luciano Date: Mon, 22 Jun 2026 18:03:29 +0200 Subject: [PATCH] Waveform: decode peaks in-renderer via Web Audio (no external ffmpeg needed) The packaged app doesn't bundle ffmpeg, so on Macs without a system ffmpeg the waveform stayed blank. Compute peaks by reading the file bytes (new file:read IPC) and decoding with Web Audio's decodeAudioData in a low-rate AudioContext; ffmpeg in main remains only as a fallback. Co-Authored-By: Claude Opus 4.8 --- main.js | 7 +++++++ preload.js | 3 ++- src/control.js | 43 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/main.js b/main.js index dac5f1b..8bd4f9b 100644 --- a/main.js +++ b/main.js @@ -134,6 +134,13 @@ function transcodeToMp4(input, output, opts) { }); } +// 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(); diff --git a/preload.js b/preload.js index 328e90c..d31e75d 100644 --- a/preload.js +++ b/preload.js @@ -35,5 +35,6 @@ contextBridge.exposeInMainWorld('djv', { savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data), exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', data), importPlaylist: () => ipcRenderer.invoke('playlist:import'), - peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets) + peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets), + readFile: (path) => ipcRenderer.invoke('file:read', path) }); diff --git a/src/control.js b/src/control.js index 05b2c99..379c35a 100644 --- a/src/control.js +++ b/src/control.js @@ -206,14 +206,47 @@ const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'lo 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. +// A low-rate AudioContext used only to decode files into waveform peaks. +let _peakCtx = null; +function peakCtx() { + if (!_peakCtx) { + const AC = window.AudioContext || window.webkitAudioContext; + try { _peakCtx = new AC({ sampleRate: 8000 }); } catch (e) { _peakCtx = new AC(); } + } + return _peakCtx; +} +function peaksFromAudioBuffer(buf, buckets) { + const ch = buf.getChannelData(0); + const n = ch.length, per = Math.max(1, Math.floor(n / buckets)); + const peaks = new Array(buckets).fill(0); + let top = 0.0001; + for (let b = 0; b < buckets; b++) { + let m = 0; const s = b * per, e = Math.min(n, s + per); + for (let i = s; i < e; i++) { const v = ch[i] < 0 ? -ch[i] : ch[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); + return peaks; +} +// Decode a file's waveform in the renderer via Web Audio (no external ffmpeg). +function decodePeaksWebAudio(path) { + return djv.readFile(path).then((bytes) => { + if (!bytes || !bytes.byteLength) throw new Error('no bytes'); + const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return peakCtx().decodeAudioData(ab); + }).then((audioBuf) => peaksFromAudioBuffer(audioBuf, WAVE_BUCKETS)); +} + +// Lazily compute the waveform peaks for a file, then redraw its card. Tries +// Web Audio first (works everywhere); falls back to ffmpeg in main if present. 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; }); + decodePeaksWebAudio(path) + .then((p) => { peaksCache[path] = p; renderPlaylist(); }) + .catch(() => 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.