docs: add audio conversion design spec
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user