8.5 KiB
Audio conversion (mp3, wav, ogg, flac, aac, m4a)
Date: 2026-08-01
Goal
Add a new audio family. Formats: mp3, wav, ogg, flac, aac, m4a. All-pairs, excluding sourceFormat === targetFormat (same rule as image/document/font/ebook — no same-format recompression use case here the way archive has).
Net registration: 6 × 5 = 30 pairs, family 'audio'.
ffmpeg is already installed on o2switch with FFMPEG_PATH set by the user. Locally, ffmpeg is not installed — tests must not require a real ffmpeg binary (see Testing).
Architecture: single ffmpeg invocation, no per-format extractor/creator maps
Unlike archive.js (which needs a distinct extractor and creator per format because each archive format is a different container library), ffmpeg's demuxer auto-detects the input from content, so one convert() handles every source format. Only the target side branches, to pick the output codec:
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 = ['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.includes(targetFormat)) {
args.push('-b:a', `${quality}k`);
}
args.push(outputPath);
await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
},
});
}
}
}
-y overwrites the output path unconditionally — needed because worker.js computes outputFilePath deterministically and ffmpeg otherwise prompts on an existing file (irrelevant for a fresh temp path today, but harmless and future-proof, matching how the storage layer always writes to a not-yet-existing UUID path).
Container note: ffmpeg picks the muxer from the output file's extension, so output.m4a automatically gets an MP4/M4A container around the aac stream — no explicit -f/muxer flag needed.
Quality: reuse quality as bitrate (kbps), fixed chip values only
No schema change — same ConversionJob.quality (Int?) column archive/image already reuse.
isValidQuality(targetFormat, quality) in src/app.js gains:
if (['mp3', 'ogg', 'aac', 'm4a'].includes(targetFormat)) return quality === null || [128, 192, 256, 320].includes(quality);
if (['wav', 'flac'].includes(targetFormat)) return quality === null;
Fixed chip values only (not an arbitrary 64–320 range) — matches the frontend's chip-only UI (see Frontend) and keeps validation simple and exhaustive rather than an open numeric range nothing in the UI can produce.
quality == null → convert() omits -b:a entirely and lets ffmpeg pick each codec's own default bitrate, same null-handling precedent as image.js.
Timeout
Reuses the existing global JOB_TIMEOUT_MS (60s) in src/worker.js — no new AUDIO_JOB_TIMEOUT_MS. ffmpeg is fast relative to calibre (the reason ebook gets its own longer timeout); confirmed with the user this is an acceptable default for now.
src/mime.js
OUTPUT_MIME_TYPES additions: mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/x-m4a' (confirmed against file-type's own ftyp-box brand-major switch in source/index.js, which maps the M4A brand to mime: 'audio/x-m4a' — matching that here keeps the input-detected mime and the output-served mime consistent for round-trip m4a jobs).
resolveInputFormat/normalizeFormat: no changes needed. Confirmed by reading the installed file-type v22's source/index.js detection logic directly (not just the extension list in source/supported.js) for all six formats — each reports ext exactly matching our declared format string, so the existing identity-fallback comparison in normalizeFormat already works with no alias mapping to add (unlike archive's tar.gz → gz outer-layer aliasing):
wav:RIFF....WAVE(bytes 0-3RIFF, bytes 8-11WAVE)flac:fLaC4-byte magicogg:OggS+ 28 bytes ignored + an 8-byte packet-type field starting\x01vorbismp3: MPEG frame sync — byte00xFF, byte1 masked0xE0(sync), then byte1 masked0x06==0x02(layer 3)aac: MPEG frame sync — byte00xFF, byte1 masked0xE0(sync), then byte1 masked0x16==0x10(ADTS, not layer 3)m4a: ISO-BMFFftypbox — bytes 4-7ftyp, bytes 8-11 major brandM4A(space-padded)
Registration wiring
src/app.js and src/worker.js: add import { registerAudioConverters } from './converters/audio.js' and one call inside registerAllConverters() / main(), alongside the existing eight.
New dependency: none. ffmpeg is invoked as an external binary via execFile, same category as CALIBRE_PATH/ebook-convert — no npm package needed, nothing to add to package.json.
Frontend
frontend/src/data/formats.js: new entry{ key: 'audio', formats: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'] }.frontend/src/utils/fileFamily.js+FormatsGrid.jsx:FAMILY_ICONS.audio—MusicNotesicon from@phosphor-icons/react(already a dependency).frontend/src/index.css: new--color-family-audioin both the light (:root) and dark theme blocks, alongside the other--color-family-*vars.frontend/src/components/FileConfigCard.jsx: new chip-group block (same pattern asICON_SIZES, not theRangeFieldcontinuous-slider pattern used for JPEG quality/archive compression) —AUDIO_BITRATE_FORMATS = ['mp3', 'ogg', 'aac', 'm4a'],BITRATES = [128, 192, 256, 320], rendered as chips wired to the existingonQualityChange(reuses thequalityfield/prop — no new prop, no new job column). No control at all forwav/flactargets, matching thegif/tarprecedent of nothing-to-tune formats.frontend/src/pages/HomePage.jsx:DEFAULT_QUALITYgainsmp3: 192, ogg: 192, aac: 192, m4a: 192(wav/flacomitted → defaultnull, consistent withdefaultQualityForreturningnullfor anything absent from the map).frontend/src/components/FormatMarquee.jsx: add one pair,['wav', 'mp3'].frontend/src/locales/{en,fr}.json:formats.audiotitle string (e.g. en: "Audio", fr: "Audio").
No DB/schema changes anywhere in this feature.
Testing
test/converters/audio.test.js, same convention as ebook.test.js: mock node:child_process's execFile via vi.mock, do not shell out to a real ffmpeg binary (ffmpeg is not installed on the local dev machine). Covers:
- registration of all 30 pairs, family
'audio', and confirmssourceFormat === targetFormatis never registered (mirrors the ebook registration test). - correct
-c:acodec args per target format. -b:a {q}kappended only whenqualityis given and the target is bitrate-capable; omitted forwav/flacand whenqualityisnull.FFMPEG_PATHenv var used when set,'ffmpeg'fallback otherwise (same shape as the existingCALIBRE_PATHtest).timeoutMsforwarded asexecFile'stimeoutoption.- a rejected
execFilecall propagates as a rejected promise.
test/mime.test.js: cases confirming the 6 new OUTPUT_MIME_TYPES entries and that resolveInputFormat accepts each format's file-type-detected extension as-is (no normalization needed).
test/api/jobs.test.js: extend the existing quality-validation cases with the new audio branches (128/192/256/320 accepted for mp3/ogg/aac/m4a; anything else, including arbitrary in-between values, rejected; only null accepted for wav/flac).
Frontend: manual browser check once the backend lands (upload a .wav, confirm target chips include the other 5 formats, bitrate chips appear only for mp3/ogg/aac/m4a, and disappear for wav/flac). Actual end-to-end conversion can only be verified where ffmpeg is present (o2switch, or a local machine with ffmpeg installed) — noted as a known gap in local manual testing per the user.