feat(audio): add ffmpeg-backed audio converter for mp3/wav/ogg/flac/aac/m4a
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { register } from './registry.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const AUDIO_FORMATS = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
|
||||
const CODEC_ARGS = {
|
||||
mp3: ['-c:a', 'libmp3lame'],
|
||||
ogg: ['-c:a', 'libvorbis'],
|
||||
aac: ['-c:a', 'aac'],
|
||||
m4a: ['-c:a', 'aac'],
|
||||
wav: ['-c:a', 'pcm_s16le'],
|
||||
flac: ['-c:a', 'flac'],
|
||||
};
|
||||
|
||||
const BITRATE_CAPABLE_FORMATS = ['mp3', 'ogg', 'aac', 'm4a'];
|
||||
|
||||
export function registerAudioConverters() {
|
||||
for (const sourceFormat of AUDIO_FORMATS) {
|
||||
for (const targetFormat of AUDIO_FORMATS) {
|
||||
if (sourceFormat === targetFormat) continue;
|
||||
register({
|
||||
family: 'audio',
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
convert: async (inputPath, outputPath, { quality, timeoutMs } = {}) => {
|
||||
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const args = ['-y', '-i', inputPath, ...CODEC_ARGS[targetFormat]];
|
||||
if (quality != null && BITRATE_CAPABLE_FORMATS.includes(targetFormat)) {
|
||||
args.push('-b:a', `${quality}k`);
|
||||
}
|
||||
args.push(outputPath);
|
||||
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { registerAudioConverters, AUDIO_FORMATS } from '../../src/converters/audio.js';
|
||||
import { resolve, _resetForTests } from '../../src/converters/registry.js';
|
||||
|
||||
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTests();
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', ''));
|
||||
});
|
||||
|
||||
describe('registerAudioConverters — registration', () => {
|
||||
it('registers every pair among the 6 audio formats, and nothing for source === target', () => {
|
||||
registerAudioConverters();
|
||||
|
||||
for (const sourceFormat of AUDIO_FORMATS) {
|
||||
for (const targetFormat of AUDIO_FORMATS) {
|
||||
const entry = resolve(sourceFormat, targetFormat);
|
||||
if (sourceFormat === targetFormat) {
|
||||
expect(entry).toBeNull();
|
||||
} else {
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry.family).toBe('audio');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes exactly the 6 requested formats', () => {
|
||||
expect(AUDIO_FORMATS.sort()).toEqual(['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerAudioConverters — subprocess invocation', () => {
|
||||
beforeEach(() => {
|
||||
registerAudioConverters();
|
||||
});
|
||||
|
||||
it('calls ffmpeg with -i, the codec args, and the output path, omitting -b:a when no quality is given', async () => {
|
||||
await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3');
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
const [file, args, options] = execFileMock.mock.calls[0];
|
||||
expect(file).toBe('ffmpeg');
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '/tmp/out.mp3']);
|
||||
expect(options).toEqual({ timeout: undefined });
|
||||
});
|
||||
|
||||
it('appends -b:a when a quality/bitrate is given for a bitrate-capable target', async () => {
|
||||
await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3', { quality: 192 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '-b:a', '192k', '/tmp/out.mp3']);
|
||||
});
|
||||
|
||||
it('never appends -b:a for a wav target even if a quality is passed', async () => {
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { quality: 192 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.mp3', '-c:a', 'pcm_s16le', '/tmp/out.wav']);
|
||||
});
|
||||
|
||||
it('never appends -b:a for a flac target even if a quality is passed', async () => {
|
||||
await resolve('wav', 'flac').convert('/tmp/in.wav', '/tmp/out.flac', { quality: 320 });
|
||||
|
||||
const [, args] = execFileMock.mock.calls[0];
|
||||
expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'flac', '/tmp/out.flac']);
|
||||
});
|
||||
|
||||
it('uses the aac codec for both aac and m4a targets', async () => {
|
||||
await resolve('wav', 'aac').convert('/tmp/in.wav', '/tmp/out.aac');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.aac']);
|
||||
|
||||
execFileMock.mockClear();
|
||||
await resolve('wav', 'm4a').convert('/tmp/in.wav', '/tmp/out.m4a');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.m4a']);
|
||||
});
|
||||
|
||||
it('forwards options.timeoutMs to execFile as its timeout', async () => {
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { timeoutMs: 60000 });
|
||||
|
||||
const [, , options] = execFileMock.mock.calls[0];
|
||||
expect(options).toEqual({ timeout: 60000 });
|
||||
});
|
||||
|
||||
it('uses FFMPEG_PATH from the environment when set', async () => {
|
||||
const previous = process.env.FFMPEG_PATH;
|
||||
process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';
|
||||
|
||||
await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav');
|
||||
|
||||
expect(execFileMock.mock.calls[0][0]).toBe('/opt/ffmpeg/bin/ffmpeg');
|
||||
|
||||
if (previous === undefined) delete process.env.FFMPEG_PATH;
|
||||
else process.env.FFMPEG_PATH = previous;
|
||||
});
|
||||
|
||||
it('propagates a rejection from execFile as a rejected promise', async () => {
|
||||
execFileMock.mockImplementation((file, args, options, callback) =>
|
||||
callback(new Error('ffmpeg exited with code 1'), '', 'Unknown encoder')
|
||||
);
|
||||
|
||||
await expect(resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav')).rejects.toThrow(
|
||||
/exited with code 1/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user