mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
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:
+15
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user