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:
1 parent
c98ebda03f
commit
0c2f2b8190
3 files changed
+47
-6
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user