126 lines
8.5 KiB
Markdown
126 lines
8.5 KiB
Markdown
# 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/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-3 `RIFF`, bytes 8-11 `WAVE`)
|
||
- `flac`: `fLaC` 4-byte magic
|
||
- `ogg`: `OggS` + 28 bytes ignored + an 8-byte packet-type field starting `\x01vorbis`
|
||
- `mp3`: MPEG frame sync — byte0 `0xFF`, byte1 masked `0xE0` (sync), then byte1 masked `0x06` == `0x02` (layer 3)
|
||
- `aac`: MPEG frame sync — byte0 `0xFF`, byte1 masked `0xE0` (sync), then byte1 masked `0x16` == `0x10` (ADTS, not layer 3)
|
||
- `m4a`: ISO-BMFF `ftyp` box — bytes 4-7 `ftyp`, bytes 8-11 major brand `M4A ` (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` — `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.
|