# 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: ```js 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: ```js 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/mp4'`. `resolveInputFormat`/`normalizeFormat`: no changes needed. Confirmed by reading the installed `file-type` v22's `source/supported.js` — `mp3`, `wav`, `ogg`, `flac`, `aac`, `m4a` are all listed verbatim as detectable extensions, each matching our declared format string exactly (unlike archive's `tar.gz` → `gz` outer-layer aliasing, there's no alias mapping to add here). ## 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` — `MusicNotes` icon from `@phosphor-icons/react` (already a dependency). - `frontend/src/index.css`: new `--color-family-audio` in 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 as `ICON_SIZES`, not the `RangeField` continuous-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 existing `onQualityChange` (reuses the `quality` field/prop — no new prop, no new job column). No control at all for `wav`/`flac` targets, matching the `gif`/`tar` precedent of nothing-to-tune formats. - `frontend/src/pages/HomePage.jsx`: `DEFAULT_QUALITY` gains `mp3: 192, ogg: 192, aac: 192, m4a: 192` (`wav`/`flac` omitted → default `null`, consistent with `defaultQualityFor` returning `null` for anything absent from the map). - `frontend/src/components/FormatMarquee.jsx`: add one pair, `['wav', 'mp3']`. - `frontend/src/locales/{en,fr}.json`: `formats.audio` title 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 confirms `sourceFormat === targetFormat` is never registered (mirrors the ebook registration test). - correct `-c:a` codec args per target format. - `-b:a {q}k` appended only when `quality` is given and the target is bitrate-capable; omitted for `wav`/`flac` and when `quality` is `null`. - `FFMPEG_PATH` env var used when set, `'ffmpeg'` fallback otherwise (same shape as the existing `CALIBRE_PATH` test). - `timeoutMs` forwarded as `execFile`'s `timeout` option. - a rejected `execFile` call 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.