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

7.7 KiB
Raw Blame History

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:

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:

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 == nullconvert() 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.jsmp3, wav, ogg, flac, aac, m4a are all listed verbatim as detectable extensions, each matching our declared format string exactly (unlike archive's tar.gzgz 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.audioMusicNotes 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.