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 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-22 18:03:29 +02:00
1 parent c98ebda03f
commit 0c2f2b8190
3 files changed
+47 -6

No files matched your search

+7
View File
@@ -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` // 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. // peak amplitudes (0..1) for drawing a waveform. Returns null on failure.
const peaksCache = new Map(); const peaksCache = new Map();
+2 -1
View File
@@ -35,5 +35,6 @@ contextBridge.exposeInMainWorld('djv', {
savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data), savePlaylist: (data) => ipcRenderer.invoke('playlist:save', data),
exportPlaylist: (data) => ipcRenderer.invoke('playlist:export', 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) peaks: (path, buckets) => ipcRenderer.invoke('audio:peaks', path, buckets),
readFile: (path) => ipcRenderer.invoke('file:read', path)
}); });
+38 -5
View File
@@ -206,14 +206,47 @@ const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'lo
const WAVE_BUCKETS = 400; const WAVE_BUCKETS = 400;
let playCur = 0, playDur = 0; // live progress of the currently playing track 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) { function ensurePeaks(path) {
if (!path || peaksCache[path]) return; if (!path || peaksCache[path]) return;
peaksCache[path] = 'loading'; peaksCache[path] = 'loading';
djv.peaks(path, WAVE_BUCKETS).then((p) => { decodePeaksWebAudio(path)
peaksCache[path] = p && p.length ? p : null; .then((p) => { peaksCache[path] = p; renderPlaylist(); })
renderPlaylist(); .catch(() => djv.peaks(path, WAVE_BUCKETS)
}).catch(() => { peaksCache[path] = null; }); .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. // Draw a waveform on a canvas, shading the trimmed-out parts and the playhead.