Files
convert/docs/superpowers/specs/2026-08-01-audio-conversion-design.md
T

120 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 64320 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.