From 05be353664a1969f5dd5c4ec922e66d0f942593b Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 14:13:09 +0200 Subject: [PATCH] feat(audio): add ffmpeg-backed audio converter for mp3/wav/ogg/flac/aac/m4a --- src/converters/audio.js | 40 +++++++++++++ test/converters/audio.test.js | 109 ++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/converters/audio.js create mode 100644 test/converters/audio.test.js diff --git a/src/converters/audio.js b/src/converters/audio.js new file mode 100644 index 0000000..91c6b6a --- /dev/null +++ b/src/converters/audio.js @@ -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 }); + }, + }); + } + } +} diff --git a/test/converters/audio.test.js b/test/converters/audio.test.js new file mode 100644 index 0000000..e1b57f6 --- /dev/null +++ b/test/converters/audio.test.js @@ -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/ + ); + }); +});