feat(audio): implement ffmpeg-based audio operations backend

Adds AudioOperations.js with pure argument builders (convert/trim/extract/merge)
plus one executeOperation that spawns ffmpeg via a dependency-injected execFileFn,
so tests never invoke a real binary. Wires process-audio-operation in main.js and
updates preload.js's ALLOWED_SEND_CHANNELS to replace the 5 stale audio-* entries.

Amit Haridas
This commit is contained in:
2026-08-23 19:31:33 +05:30
parent 174eb3d6e9
commit a353a695b5
4 changed files with 250 additions and 5 deletions
+15
View File
@@ -6,6 +6,7 @@ const { execFile } = require('child_process');
const WordTemplateExporter = require('./wordTemplateExporter');
const PDFOperations = require('./main/PDFOperations');
const ImageOperations = require('./main/ImageOperations');
const AudioOperations = require('./main/AudioOperations');
const GitOperations = require('./main/GitOperations');
const PdfFontHeader = require('./main/PdfFontHeader');
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
@@ -4636,6 +4637,20 @@ ipcMain.handle('process-image-operation', async (event, { operation, data }) =>
}
});
// ========================================
// AUDIO OPERATIONS — delegates to main/AudioOperations.js
// ========================================
ipcMain.handle('process-audio-operation', async (event, { operation, data }) => {
try {
return await AudioOperations.executeOperation(operation, data, {
ffmpegPath: getFFmpegPath(),
});
} catch (error) {
return { success: false, error: sanitizeErrorMessage(error.message) };
}
});
// IPC Handler for folder selection (for batch image operations)
ipcMain.on('select-image-folder', (event, inputId) => {
const folder = dialog.showOpenDialogSync(mainWindow, {
+171
View File
@@ -0,0 +1,171 @@
/**
* Audio Operations Module
*
* Handles audio manipulation via `ffmpeg`: format conversion, trim, extract (audio
* track from video/audio), and merge (concat demuxer). Because ffmpeg is an external
* binary, this module is split into pure/testable argument-builder functions and a
* single `executeOperation` that is the only piece which actually spawns ffmpeg —
* the ffmpeg binary path and the `execFile` implementation are both injected so tests
* can replace them with fakes, without invoking a real binary.
*
* @module AudioOperations
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
/**
* Build args for a straight format conversion. ffmpeg infers the output format from
* outputPath's extension; only pass -f explicitly when format is given and differs
* from that extension.
*/
function buildConvertArgs({ inputPath, outputPath, format }) {
const args = ['-i', inputPath, '-y'];
if (format) {
const ext = path.extname(outputPath).replace(/^\./, '').toLowerCase();
if (format.toLowerCase() !== ext) {
args.push('-f', format);
}
}
args.push(outputPath);
return args;
}
/**
* Build args to trim inputPath to [startTime, startTime + duration) seconds.
* startTime/duration must be finite, non-negative numbers — they become argv
* elements passed straight to execFile with no shell involved, so there's no
* injection risk, but malformed values should still fail fast rather than reach
* ffmpeg with garbage.
*/
function buildTrimArgs({ inputPath, outputPath, startTime, duration }) {
const isValid = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
if (!isValid(startTime) || !isValid(duration)) {
throw new Error('Invalid trim range');
}
return ['-i', inputPath, '-ss', String(startTime), '-t', String(duration), '-y', outputPath];
}
/**
* Build args to extract the audio track (stream copy, no video, no re-encode).
* If codec copy fails, executeOperation's 'extract' case retries without -acodec copy.
*/
function buildExtractArgs({ inputPath, outputPath }) {
return ['-i', inputPath, '-vn', '-acodec', 'copy', '-y', outputPath];
}
/**
* Fallback extract args that let ffmpeg transcode instead of stream-copying.
*/
function buildExtractFallbackArgs({ inputPath, outputPath }) {
return ['-i', inputPath, '-vn', '-y', outputPath];
}
/**
* Build args to merge 2+ files via the concat demuxer. Returns both concatListContent
* (the `file '<path>'` lines the caller writes to a temp list file) and args — the
* caller doesn't know the temp list file's path until it creates it, so tempListPath
* is an optional param: executeOperation's 'merge' case calls this once to obtain
* concatListContent, writes it to disk, then calls it again with the real
* tempListPath to obtain the final args referencing that file.
*/
function buildMergeArgs({ inputPaths, outputPath, tempListPath = null }) {
if (!Array.isArray(inputPaths) || inputPaths.length < 2) {
throw new Error('inputPaths must contain at least 2 files');
}
const concatListContent = inputPaths.map((p) => `file '${p}'`).join('\n') + '\n';
const args = ['-f', 'concat', '-safe', '0', '-i', tempListPath, '-c', 'copy', '-y', outputPath];
return { args, concatListContent };
}
/**
* Run ffmpeg with the given args via the injected execFileFn, wrapped in a Promise.
*/
function runFfmpeg(ffmpegPath, args, execFileFn) {
return new Promise((resolve, reject) => {
execFileFn(ffmpegPath, args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr || error.message));
return;
}
resolve();
});
});
}
async function executeOperation(operation, data, { ffmpegPath, execFileFn } = {}) {
const resolvedFfmpegPath = ffmpegPath || 'ffmpeg';
const resolvedExecFileFn = execFileFn || execFile;
switch (operation) {
case 'convert': {
const { inputPath, outputPath, format } = data || {};
const args = buildConvertArgs({ inputPath, outputPath, format });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
case 'trim': {
const { inputPath, outputPath, startTime, duration } = data || {};
const args = buildTrimArgs({ inputPath, outputPath, startTime, duration });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
return { success: true, outputPath };
}
case 'extract': {
const { inputPath, outputPath } = data || {};
try {
const args = buildExtractArgs({ inputPath, outputPath });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
} catch {
// Codec copy can fail when the source audio codec isn't valid in the target
// container — fall back to letting ffmpeg transcode instead.
const fallbackArgs = buildExtractFallbackArgs({ inputPath, outputPath });
await runFfmpeg(resolvedFfmpegPath, fallbackArgs, resolvedExecFileFn);
}
return { success: true, outputPath };
}
case 'merge': {
const { inputPaths, outputPath } = data || {};
const { concatListContent } = buildMergeArgs({ inputPaths, outputPath });
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'audio-merge-'));
const tempListPath = path.join(tempDir, 'concat-list.txt');
try {
fs.writeFileSync(tempListPath, concatListContent, 'utf8');
const { args } = buildMergeArgs({ inputPaths, outputPath, tempListPath });
await runFfmpeg(resolvedFfmpegPath, args, resolvedExecFileFn);
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
/* best-effort cleanup */
}
}
return { success: true, outputPath };
}
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
module.exports = {
executeOperation,
buildConvertArgs,
buildTrimArgs,
buildExtractArgs,
buildMergeArgs,
};
+1 -5
View File
@@ -49,11 +49,7 @@ const ALLOWED_SEND_CHANNELS = [
'select-image-folder',
// Audio converter
'audio-convert',
'audio-batch-convert',
'audio-extract',
'audio-trim',
'audio-merge',
'process-audio-operation',
// Video converter
'video-convert',
+63
View File
@@ -0,0 +1,63 @@
const AudioOperations = require('../../src/main/AudioOperations');
describe('AudioOperations argument builders', () => {
test('buildConvertArgs builds correct ffmpeg args', () => {
const args = AudioOperations.buildConvertArgs({ inputPath: '/a.wav', outputPath: '/b.mp3' });
expect(args).toEqual(['-i', '/a.wav', '-y', '/b.mp3']);
});
test('buildTrimArgs builds correct trim args', () => {
const args = AudioOperations.buildTrimArgs({
inputPath: '/a.mp3',
outputPath: '/b.mp3',
startTime: 5,
duration: 10,
});
expect(args).toEqual(['-i', '/a.mp3', '-ss', '5', '-t', '10', '-y', '/b.mp3']);
});
test('buildTrimArgs rejects non-finite startTime', () => {
expect(() =>
AudioOperations.buildTrimArgs({
inputPath: '/a.mp3',
outputPath: '/b.mp3',
startTime: NaN,
duration: 10,
})
).toThrow('Invalid trim range');
});
test('buildMergeArgs builds concat-demuxer args and list content', () => {
const { args, concatListContent } = AudioOperations.buildMergeArgs({
inputPaths: ['/a.mp3', '/b.mp3'],
outputPath: '/out.mp3',
});
expect(concatListContent).toContain("file '/a.mp3'");
expect(concatListContent).toContain("file '/b.mp3'");
expect(args).toContain('-f');
expect(args).toContain('concat');
});
});
describe('AudioOperations.executeOperation', () => {
test('convert calls execFileFn with ffmpeg path and args, resolves success', async () => {
const execFileFn = (cmd, args, opts, cb) => cb(null, '', '');
const result = await AudioOperations.executeOperation(
'convert',
{ inputPath: '/a.wav', outputPath: '/b.mp3' },
{ ffmpegPath: '/usr/bin/ffmpeg', execFileFn }
);
expect(result.success).toBe(true);
expect(result.outputPath).toBe('/b.mp3');
});
test('unknown operation rejects', async () => {
await expect(
AudioOperations.executeOperation(
'bogus',
{},
{ ffmpegPath: '/usr/bin/ffmpeg', execFileFn: () => {} }
)
).rejects.toThrow();
});
});