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 WordTemplateExporter = require('./wordTemplateExporter');
|
||||||
const PDFOperations = require('./main/PDFOperations');
|
const PDFOperations = require('./main/PDFOperations');
|
||||||
const ImageOperations = require('./main/ImageOperations');
|
const ImageOperations = require('./main/ImageOperations');
|
||||||
|
const AudioOperations = require('./main/AudioOperations');
|
||||||
const GitOperations = require('./main/GitOperations');
|
const GitOperations = require('./main/GitOperations');
|
||||||
const PdfFontHeader = require('./main/PdfFontHeader');
|
const PdfFontHeader = require('./main/PdfFontHeader');
|
||||||
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
|
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)
|
// IPC Handler for folder selection (for batch image operations)
|
||||||
ipcMain.on('select-image-folder', (event, inputId) => {
|
ipcMain.on('select-image-folder', (event, inputId) => {
|
||||||
const folder = dialog.showOpenDialogSync(mainWindow, {
|
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',
|
'select-image-folder',
|
||||||
|
|
||||||
// Audio converter
|
// Audio converter
|
||||||
'audio-convert',
|
'process-audio-operation',
|
||||||
'audio-batch-convert',
|
|
||||||
'audio-extract',
|
|
||||||
'audio-trim',
|
|
||||||
'audio-merge',
|
|
||||||
|
|
||||||
// Video converter
|
// Video converter
|
||||||
'video-convert',
|
'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