From a23ccf465cc7d2560185b97dd689e67fe631a5e8 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Sat, 1 Aug 2026 13:34:44 +0200 Subject: [PATCH] docs: add audio conversion design spec --- .../2026-08-01-audio-conversion-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-audio-conversion-design.md diff --git a/docs/superpowers/specs/2026-08-01-audio-conversion-design.md b/docs/superpowers/specs/2026-08-01-audio-conversion-design.md new file mode 100644 index 0000000..7c0eeb8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-audio-conversion-design.md @@ -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.