Files
convert/docs/superpowers/plans/2026-08-01-audio-conversion.md

32 KiB

Audio Conversion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a new audio conversion family (mp3, wav, ogg, flac, aac, m4a; any-to-any, 30 pairs) backed by an external ffmpeg binary, following the exact CALIBRE_PATH/ebook.js pattern already in this codebase.

Architecture: One generic convert() in src/converters/audio.js that shells out to ffmpeg via execFile (no ffmpeg npm wrapper — ffmpeg's own demuxer auto-detects the input format, so there is no per-source branching, only per-target codec selection). Wired into app.js/worker.js next to the other seven registerXConverters() calls. Bitrate (kbps) is stored in the existing nullable ConversionJob.quality column — no schema change.

Tech Stack: Node.js, node:child_process (execFile), Express, Prisma/MySQL, Vitest + Supertest, React (frontend, manual verification only — no frontend test suite exists in this repo).

Global Constraints

  • Spec: docs/superpowers/specs/2026-08-01-audio-conversion-design.md — read it before starting if anything below is unclear.
  • Formats: exactly mp3, wav, ogg, flac, aac, m4a. All-pairs excluding sourceFormat === targetFormat.
  • ffmpeg is not installed on this local dev machine. Every automated test in this plan mocks node:child_process's execFile — none may shell out to a real ffmpeg binary. Real end-to-end conversion is verified only manually on a machine/environment where ffmpeg is present (o2switch), never in this plan's automated tests.
  • Bitrate is a fixed chip set only: 128, 192, 256, 320 (kbps). No arbitrary numeric range is valid.
  • wav and flac never take a quality/bitrate value — only null is valid for them.
  • Timeout: reuse the existing global JOB_TIMEOUT_MS (60s) in src/worker.js. Do not add a new AUDIO_JOB_TIMEOUT_MS.
  • No new npm dependency. ffmpeg is invoked as an external binary (process.env.FFMPEG_PATH || 'ffmpeg'), the same way ebook.js invokes process.env.CALIBRE_PATH || 'ebook-convert'.
  • No DB/schema changes anywhere in this feature.
  • Run backend tests with the project's documented local-env-var pattern (see CLAUDE.md): DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run <path>.

Task 1: Audio converter core (src/converters/audio.js)

Files:

  • Create: src/converters/audio.js
  • Test: test/converters/audio.test.js

Interfaces:

  • Consumes: register from ./registry.js (signature: register({ family, sourceFormat, targetFormat, convert }), already used identically by every other converter file).

  • Produces: registerAudioConverters(): void and AUDIO_FORMATS: string[] (both named exports), for Task 2 to import and call/reference. Each registered convert(inputPath, outputPath, { quality, timeoutMs } = {}) returns a Promise<void>.

  • Step 1: Write the failing test for registration coverage

Create test/converters/audio.test.js with this content (mirrors test/converters/ebook.test.js's structure exactly, including the execFile mock):

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { registerAudioConverters, AUDIO_FORMATS } from '../../src/converters/audio.js';
import { resolve, _resetForTests } from '../../src/converters/registry.js';

const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', () => ({ execFile: execFileMock }));

beforeEach(() => {
  _resetForTests();
  execFileMock.mockReset();
  execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', ''));
});

describe('registerAudioConverters — registration', () => {
  it('registers every pair among the 6 audio formats, and nothing for source === target', () => {
    registerAudioConverters();

    for (const sourceFormat of AUDIO_FORMATS) {
      for (const targetFormat of AUDIO_FORMATS) {
        const entry = resolve(sourceFormat, targetFormat);
        if (sourceFormat === targetFormat) {
          expect(entry).toBeNull();
        } else {
          expect(entry).not.toBeNull();
          expect(entry.family).toBe('audio');
        }
      }
    }
  });

  it('exposes exactly the 6 requested formats', () => {
    expect(AUDIO_FORMATS.sort()).toEqual(['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav'].sort());
  });
});
  • Step 2: Run test to verify it fails

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/audio.test.js Expected: FAIL — Cannot find module '../../src/converters/audio.js' (or similar "does not provide an export named" once the file exists but before exports are added).

  • Step 3: Write the converter implementation

Create src/converters/audio.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_FORMATS = ['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_FORMATS.includes(targetFormat)) {
            args.push('-b:a', `${quality}k`);
          }
          args.push(outputPath);
          await execFileAsync(ffmpegPath, args, { timeout: timeoutMs });
        },
      });
    }
  }
}
  • Step 4: Run test to verify it passes

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/audio.test.js Expected: PASS (2 tests)

  • Step 5: Add subprocess-invocation tests (codec args, bitrate, env var, timeout, error propagation)

Append to test/converters/audio.test.js:

describe('registerAudioConverters — subprocess invocation', () => {
  beforeEach(() => {
    registerAudioConverters();
  });

  it('calls ffmpeg with -i, the codec args, and the output path, omitting -b:a when no quality is given', async () => {
    await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3');

    expect(execFileMock).toHaveBeenCalledTimes(1);
    const [file, args, options] = execFileMock.mock.calls[0];
    expect(file).toBe('ffmpeg');
    expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '/tmp/out.mp3']);
    expect(options).toEqual({ timeout: undefined });
  });

  it('appends -b:a when a quality/bitrate is given for a bitrate-capable target', async () => {
    await resolve('wav', 'mp3').convert('/tmp/in.wav', '/tmp/out.mp3', { quality: 192 });

    const [, args] = execFileMock.mock.calls[0];
    expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'libmp3lame', '-b:a', '192k', '/tmp/out.mp3']);
  });

  it('never appends -b:a for a wav target even if a quality is passed', async () => {
    await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { quality: 192 });

    const [, args] = execFileMock.mock.calls[0];
    expect(args).toEqual(['-y', '-i', '/tmp/in.mp3', '-c:a', 'pcm_s16le', '/tmp/out.wav']);
  });

  it('never appends -b:a for a flac target even if a quality is passed', async () => {
    await resolve('wav', 'flac').convert('/tmp/in.wav', '/tmp/out.flac', { quality: 320 });

    const [, args] = execFileMock.mock.calls[0];
    expect(args).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'flac', '/tmp/out.flac']);
  });

  it('uses the aac codec for both aac and m4a targets', async () => {
    await resolve('wav', 'aac').convert('/tmp/in.wav', '/tmp/out.aac');
    expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.aac']);

    execFileMock.mockClear();
    await resolve('wav', 'm4a').convert('/tmp/in.wav', '/tmp/out.m4a');
    expect(execFileMock.mock.calls[0][1]).toEqual(['-y', '-i', '/tmp/in.wav', '-c:a', 'aac', '/tmp/out.m4a']);
  });

  it('forwards options.timeoutMs to execFile as its timeout', async () => {
    await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav', { timeoutMs: 60000 });

    const [, , options] = execFileMock.mock.calls[0];
    expect(options).toEqual({ timeout: 60000 });
  });

  it('uses FFMPEG_PATH from the environment when set', async () => {
    const previous = process.env.FFMPEG_PATH;
    process.env.FFMPEG_PATH = '/opt/ffmpeg/bin/ffmpeg';

    await resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav');

    expect(execFileMock.mock.calls[0][0]).toBe('/opt/ffmpeg/bin/ffmpeg');

    if (previous === undefined) delete process.env.FFMPEG_PATH;
    else process.env.FFMPEG_PATH = previous;
  });

  it('propagates a rejection from execFile as a rejected promise', async () => {
    execFileMock.mockImplementation((file, args, options, callback) =>
      callback(new Error('ffmpeg exited with code 1'), '', 'Unknown encoder')
    );

    await expect(resolve('mp3', 'wav').convert('/tmp/in.mp3', '/tmp/out.wav')).rejects.toThrow(
      /exited with code 1/
    );
  });
});
  • Step 6: Run tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/audio.test.js Expected: PASS (9 tests total)

  • Step 7: Commit
git add src/converters/audio.js test/converters/audio.test.js
git commit -m "feat(audio): add ffmpeg-backed audio converter for mp3/wav/ogg/flac/aac/m4a"

Task 2: Wire registration + bitrate validation into app.js/worker.js

Files:

  • Modify: src/app.js (import, registerAllConverters(), isValidQuality)
  • Modify: src/worker.js (import, main())
  • Test: test/api/jobs.test.js (new describe block)

Interfaces:

  • Consumes: registerAudioConverters from ./converters/audio.js (Task 1).

  • Produces: POST /api/jobs now accepts sourceFormat/targetFormat pairs among the 6 audio formats with family: 'audio', and validates quality as one of [128, 192, 256, 320] (or null) for mp3/ogg/aac/m4a, and only null for wav/flac.

  • Step 1: Write the failing integration tests

Append to test/api/jobs.test.js, after the POST /api/jobs — archives block (before its closing nothing — just append at end of file):

describe('POST /api/jobs — audio', () => {
  it('creates a pending job converting a wav upload to mp3 with a valid bitrate', async () => {
    const fixturePath = path.join(config.storageDir, 'clip.wav');
    // Minimal RIFF/WAVE header: 'RIFF' + 4-byte size (unchecked) + 'WAVE'.
    await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['mp3']))
      .field('qualities', JSON.stringify([192]))
      .attach('files', fixturePath, 'clip.wav');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].status).toBe('pending');

    const job = await getJobByUuid(prisma, response.body.jobs[0].id);
    expect(job.sourceFormat).toBe('wav');
    expect(job.targetFormat).toBe('mp3');
    expect(job.family).toBe('audio');
    expect(job.quality).toBe(192);

    await fs.unlink(fixturePath);
  });

  it('rejects a bitrate outside the fixed chip set for an mp3 target', async () => {
    const fixturePath = path.join(config.storageDir, 'bad-bitrate.wav');
    await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['mp3']))
      .field('qualities', JSON.stringify([200]))
      .attach('files', fixturePath, 'bad-bitrate.wav');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].error).toMatch(/Invalid quality/);

    await fs.unlink(fixturePath);
  });

  it('rejects a quality value for a wav target (lossless, no bitrate control)', async () => {
    const fixturePath = path.join(config.storageDir, 'to-wav.wav');
    await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['flac']))
      .field('qualities', JSON.stringify([192]))
      .attach('files', fixturePath, 'to-wav.wav');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].error).toMatch(/Invalid quality/);

    await fs.unlink(fixturePath);
  });

  it('creates a pending job converting flac to wav with no quality value', async () => {
    const fixturePath = path.join(config.storageDir, 'clip.flac');
    await fs.writeFile(fixturePath, Buffer.from('fLaC'));

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['wav']))
      .attach('files', fixturePath, 'clip.flac');

    expect(response.status).toBe(201);
    const job = await getJobByUuid(prisma, response.body.jobs[0].id);
    expect(job.sourceFormat).toBe('flac');
    expect(job.targetFormat).toBe('wav');
    expect(job.quality).toBeNull();

    await fs.unlink(fixturePath);
  });

  it('lists the other 5 audio formats as targets for mp3, and never lists mp3 as its own target', async () => {
    const response = await request(app).get('/api/formats').query({ source: 'mp3' });

    expect(response.body.targets).toEqual(expect.arrayContaining(['wav', 'ogg', 'flac', 'aac', 'm4a']));
    expect(response.body.targets).not.toContain('mp3');
  });
});
  • Step 2: Run test to verify it fails

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js Expected: FAIL — jobs come back with error: "Unsupported conversion: wav to mp3" (registry has no audio entries yet), and the mp3 case in GET /api/formats returns an empty/unrelated list.

  • Step 3: Wire registration into src/app.js

In src/app.js, add the import alongside the other converter imports (after line 16, import { registerArchiveConverters } from './converters/archive.js';):

import { registerAudioConverters } from './converters/audio.js';

In registerAllConverters() (around line 42-54), add the call alongside the other eight:

function registerAllConverters() {
  if (convertersRegistered) return;
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  registerFontConverter();
  registerDfontConverter();
  registerEbookConverter();
  registerArchiveConverters();
  registerAudioConverters();
  convertersRegistered = true;
}
  • Step 4: Add bitrate validation to isValidQuality in src/app.js

Replace the existing isValidQuality function (lines 25-32):

function isValidQuality(targetFormat, quality) {
  if (quality === null || quality === undefined) return true;
  if (!Number.isInteger(quality)) return false;
  if (targetFormat === 'gif' || targetFormat === 'ico' || targetFormat === 'tar') return false;
  if (targetFormat === 'png') return quality >= 0 && quality <= 9;
  if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9;
  if (['wav', 'flac'].includes(targetFormat)) return false;
  if (['mp3', 'ogg', 'aac', 'm4a'].includes(targetFormat)) return [128, 192, 256, 320].includes(quality);
  return quality >= 1 && quality <= 100;
}

(The wav/flac branch must come before the final fallthrough so a non-null quality for those formats is rejected rather than falling into the generic 1-100 range check.)

  • Step 5: Wire registration into src/worker.js

Add the import alongside the other converter imports (after line 16, import { registerArchiveConverters } from './converters/archive.js';):

import { registerAudioConverters } from './converters/audio.js';

In main() (around lines 85-93), add the call alongside the other eight:

  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  registerFontConverter();
  registerDfontConverter();
  registerEbookConverter();
  registerArchiveConverters();
  registerAudioConverters();
  • Step 6: Run tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js Expected: PASS (all tests in the file, including the new POST /api/jobs — audio block)

  • Step 7: Run the full backend test suite to check for regressions

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run Expected: PASS, except the two pre-existing unrelated failures documented in CLAUDE.md (test/cleanup.test.js and test/jobs/jobRepository.test.js, both clock/timezone issues predating this change — confirm no new failures beyond those two).

  • Step 8: Commit
git add src/app.js src/worker.js test/api/jobs.test.js
git commit -m "feat(audio): wire audio converters and bitrate validation into app.js/worker.js"

Task 3: MIME types for audio formats

Files:

  • Modify: src/mime.js
  • Test: test/mime.test.js

Interfaces:

  • Consumes: nothing new (existing OUTPUT_MIME_TYPES map and resolveInputFormat/detectInputMime in src/mime.js).

  • Produces: outputMimeType('mp3'|'wav'|'ogg'|'flac'|'aac'|'m4a') returns a real MIME string instead of throwing; resolveInputFormat accepts real content declared under each of these 6 formats.

  • Step 1: Write the failing tests

Append to test/mime.test.js, after the describe('resolveInputFormat — compound archive extensions', ...) block at the end of the file:

describe('outputMimeType — audio', () => {
  it('returns the correct MIME type for each audio target format', () => {
    expect(outputMimeType('mp3')).toBe('audio/mpeg');
    expect(outputMimeType('wav')).toBe('audio/wav');
    expect(outputMimeType('ogg')).toBe('audio/ogg');
    expect(outputMimeType('flac')).toBe('audio/flac');
    expect(outputMimeType('aac')).toBe('audio/aac');
    expect(outputMimeType('m4a')).toBe('audio/x-m4a');
  });
});

describe('resolveInputFormat — audio formats', () => {
  it('accepts a WAV file (RIFF....WAVE) declared as wav', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.wav');
    await fs.writeFile(fixturePath, Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WAVE')]));

    const result = await resolveInputFormat(fixturePath, 'wav');
    expect(result).toEqual({ mime: 'audio/wav', valid: true });

    await fs.unlink(fixturePath);
  });

  it('accepts a FLAC file (fLaC magic) declared as flac', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.flac');
    await fs.writeFile(fixturePath, Buffer.from('fLaC'));

    const result = await resolveInputFormat(fixturePath, 'flac');
    expect(result).toEqual({ mime: 'audio/flac', valid: true });

    await fs.unlink(fixturePath);
  });

  it('accepts an MP3 file (MPEG layer 3 frame sync) declared as mp3', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.mp3');
    // Frame sync 0xFF 0xFB: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x06 == 0x02 (layer 3, not ADTS).
    await fs.writeFile(fixturePath, Buffer.from([0xff, 0xfb, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00]));

    const result = await resolveInputFormat(fixturePath, 'mp3');
    expect(result).toEqual({ mime: 'audio/mpeg', valid: true });

    await fs.unlink(fixturePath);
  });

  it('accepts an AAC/ADTS file declared as aac', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.aac');
    // Frame sync 0xFF 0xF1: byte1 & 0xE0 == 0xE0 (sync), byte1 & 0x16 == 0x10 (ADTS, not layer 3).
    await fs.writeFile(fixturePath, Buffer.from([0xff, 0xf1, 0x4c, 0x80, 0x01, 0x3f, 0xfc, 0x00]));

    const result = await resolveInputFormat(fixturePath, 'aac');
    expect(result).toEqual({ mime: 'audio/aac', valid: true });

    await fs.unlink(fixturePath);
  });

  it('accepts an OGG/Vorbis file declared as ogg', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.ogg');
    // 'OggS' + 28 ignored bytes + 8-byte packet-type field starting '\x01vorbis'.
    const body = Buffer.concat([
      Buffer.from('OggS'),
      Buffer.alloc(28),
      Buffer.concat([Buffer.from([0x01]), Buffer.from('vorbis'), Buffer.alloc(1)]),
    ]);
    await fs.writeFile(fixturePath, body);

    const result = await resolveInputFormat(fixturePath, 'ogg');
    expect(result).toEqual({ mime: 'audio/ogg', valid: true });

    await fs.unlink(fixturePath);
  });

  it('accepts an M4A file (ISO-BMFF ftyp box, M4A brand) declared as m4a', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-audio.m4a');
    // 4-byte box size (unchecked) + 'ftyp' + major brand 'M4A ' (space-padded, 4 bytes).
    const body = Buffer.concat([Buffer.alloc(4), Buffer.from('ftyp'), Buffer.from('M4A ')]);
    await fs.writeFile(fixturePath, body);

    const result = await resolveInputFormat(fixturePath, 'm4a');
    expect(result).toEqual({ mime: 'audio/x-m4a', valid: true });

    await fs.unlink(fixturePath);
  });
});
  • Step 2: Run test to verify it fails

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js Expected: FAIL — outputMimeType('mp3') etc. throw No known MIME type for target format "mp3".

  • Step 3: Add the MIME entries in src/mime.js

In src/mime.js, add to OUTPUT_MIME_TYPES (after the existing 'tar.7z': 'application/x-7z-compressed', line):

  mp3: 'audio/mpeg',
  wav: 'audio/wav',
  ogg: 'audio/ogg',
  flac: 'audio/flac',
  aac: 'audio/aac',
  m4a: 'audio/x-m4a',

No other changes to src/mime.jsresolveInputFormat/normalizeFormat need no alias entries for these six formats (each detects with an ext that already matches the declared format string exactly).

  • Step 4: Run tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js Expected: PASS (all tests in the file)

  • Step 5: Commit
git add src/mime.js test/mime.test.js
git commit -m "feat(audio): add MIME types for mp3/wav/ogg/flac/aac/m4a"

Task 4: Frontend — recognize the audio family (icon, color, formats grid)

Files:

  • Modify: frontend/src/data/formats.js
  • Modify: frontend/src/utils/fileFamily.js
  • Modify: frontend/src/components/FormatsGrid.jsx
  • Modify: frontend/src/index.css
  • Modify: frontend/src/locales/en.json
  • Modify: frontend/src/locales/fr.json

Interfaces:

  • Consumes: nothing new from earlier tasks (this task only needs the format strings mp3/wav/ogg/flac/aac/m4a, which are plain data, not an import).

  • Produces: familyForExtension('mp3') (etc.) returns 'audio'; fileTypeMeta('mp3') returns { Icon: MusicNotes, colorVar: '--color-family-audio' }; the "Supported formats" section on the homepage shows an Audio tile listing the 6 formats. No automated test — this repo has no frontend test suite; verified manually in Task 5 once the upload flow can exercise it end-to-end.

  • Step 1: Add the audio family to frontend/src/data/formats.js

Add a new entry to the FORMAT_FAMILIES array (after the archives entry):

  {
    key: 'audio',
    formats: ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'],
  },
  • Step 2: Add the icon mapping in frontend/src/utils/fileFamily.js

Change the import line to add MusicNotes:

import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes, File as FileIcon } from '@phosphor-icons/react';

Add to FAMILY_ICONS:

const FAMILY_ICONS = {
  images: Image,
  documents: FileText,
  fonts: TextAa,
  ebooks: BookOpen,
  archives: Archive,
  audio: MusicNotes,
};
  • Step 3: Add the icon mapping in frontend/src/components/FormatsGrid.jsx

Change the import line to add MusicNotes:

import { Image, FileText, TextAa, BookOpen, Archive, MusicNotes } from '@phosphor-icons/react';

Add to FAMILY_ICONS:

const FAMILY_ICONS = {
  images: Image,
  documents: FileText,
  fonts: TextAa,
  ebooks: BookOpen,
  archives: Archive,
  audio: MusicNotes,
};
  • Step 4: Add the color variable in frontend/src/index.css

In the light-theme block (after --color-family-archives: #64748b;):

  --color-family-audio: #ec4899;

In the dark-theme block (after --color-family-archives: #94a3b8;):

  --color-family-audio: #f472b6;
  • Step 5: Add locale strings

In frontend/src/locales/en.json, in the formats object (after "archives": "Archives"):

    "archives": "Archives",
    "audio": "Audio"

In frontend/src/locales/fr.json, in the formats object (after "archives": "Archives"):

    "archives": "Archives",
    "audio": "Audio"

(Note: this adds a trailing comma to the existing archives line in both files — check the JSON is still valid after editing, since it's currently the last key in the object before the closing brace.)

  • Step 6: Manually verify in the browser

Run: npm run dev --prefix frontend (check first whether a dev server is already running per CLAUDE.md's guidance on pre-existing processes before starting a new one)

Open the homepage, scroll to "Supported formats" / "Formats pris en charge", and confirm an "Audio" tile appears listing MP3, WAV, OGG, FLAC, AAC, M4A with a pink music-note icon, matching the visual style of the other family tiles.

  • Step 7: Commit
git add frontend/src/data/formats.js frontend/src/utils/fileFamily.js frontend/src/components/FormatsGrid.jsx frontend/src/index.css frontend/src/locales/en.json frontend/src/locales/fr.json
git commit -m "feat(audio): recognize the audio family in the frontend formats grid"

Task 5: Frontend — bitrate chips, default quality, marquee pair

Files:

  • Modify: frontend/src/components/FileConfigCard.jsx
  • Modify: frontend/src/pages/HomePage.jsx
  • Modify: frontend/src/components/FormatMarquee.jsx
  • Modify: frontend/src/locales/en.json
  • Modify: frontend/src/locales/fr.json

Interfaces:

  • Consumes: GET /api/formats?source=<audio-format> (Task 2) to populate item.targets for an uploaded audio file; the quality field on pendingFiles items (existing state shape in HomePage.jsx, unchanged).

  • Produces: uploading an audio file shows bitrate chips (128/192/256/320 kbps) for mp3/ogg/aac/m4a targets, no control for wav/flac targets, and POST /api/jobs receives a qualities entry matching the selected chip.

  • Step 1: Add the bitrate chip group to frontend/src/components/FileConfigCard.jsx

Add a new constant near the top of the file (alongside QUALITY_FORMATS/ICON_SIZES):

const AUDIO_BITRATE_FORMATS = ['mp3', 'ogg', 'aac', 'm4a'];
const AUDIO_BITRATES = [128, 192, 256, 320];

Add a new conditional block inside file-config-controls, after the ARCHIVE_COMPRESSION_FORMATS block and before the item.targetFormat === 'ico' block:

          {AUDIO_BITRATE_FORMATS.includes(item.targetFormat) && (
            <div className="chip-field">
              <span>{t('quality.bitrate')}</span>
              <div className="chip-group" role="group" aria-label={t('quality.bitrate')}>
                {AUDIO_BITRATES.map((bitrate) => (
                  <button
                    key={bitrate}
                    type="button"
                    className={`chip${item.quality === bitrate ? ' chip-active' : ''}`}
                    aria-pressed={item.quality === bitrate}
                    onClick={() => onQualityChange(index, bitrate)}
                  >
                    {bitrate}kbps
                  </button>
                ))}
              </div>
            </div>
          )}
  • Step 2: Add default bitrates to frontend/src/pages/HomePage.jsx

In the DEFAULT_QUALITY object, add the 4 bitrate-capable audio formats:

const DEFAULT_QUALITY = {
  jpg: 80,
  jpeg: 80,
  webp: 80,
  avif: 50,
  tiff: 80,
  png: 6,
  zip: 6,
  'tar.gz': 6,
  'tar.bz2': 9,
  '7z': 5,
  'tar.7z': 5,
  mp3: 192,
  ogg: 192,
  aac: 192,
  m4a: 192,
};

(wav/flac are intentionally omitted — defaultQualityFor already returns null for anything absent from this map.)

  • Step 3: Add a marquee pair to frontend/src/components/FormatMarquee.jsx

Add one pair to PAIRS:

const PAIRS = [
  ['heic', 'jpg'],
  ['png', 'webp'],
  ['docx', 'pdf'],
  ['ttf', 'woff'],
  ['epub', 'mobi'],
  ['rar', 'zip'],
  ['wav', 'mp3'],
];
  • Step 4: Add the bitrate locale key

In frontend/src/locales/en.json, in the quality object (after "compressPdf": "Compress as JPEG" or whatever the last existing key is — check the file first since only fr.json was read directly; match its existing key order/style):

    "bitrate": "Bitrate"

In frontend/src/locales/fr.json, in the quality object (after "compressPdf": "Compresser en JPEG"):

    "bitrate": "Bitrate"
  • Step 5: Manually verify in the browser

With the frontend dev server running (from Task 4, Step 6), and the backend server/worker running per CLAUDE.md's guidance on pre-existing processes:

  1. Upload a .wav file. Confirm the target-format chips include MP3, OGG, FLAC, AAC, M4A (not WAV).
  2. Select MP3 as the target. Confirm bitrate chips 128kbps/192kbps/256kbps/320kbps appear, with 192kbps pre-selected.
  3. Switch the target to FLAC. Confirm the bitrate chips disappear (no quality control at all).
  4. Switch back to MP3, click convert. Confirm the job is created as pending (note: since ffmpeg is not installed locally, per CLAUDE.md guidance the job will end up failed once a worker picks it up and shells out to a nonexistent ffmpeg binary — that failure is expected in this environment and is not a regression; it confirms the job creation and validation path, not the actual conversion, which can only be confirmed where ffmpeg is present).
  • Step 6: Commit
git add frontend/src/components/FileConfigCard.jsx frontend/src/pages/HomePage.jsx frontend/src/components/FormatMarquee.jsx frontend/src/locales/en.json frontend/src/locales/fr.json
git commit -m "feat(audio): add bitrate chip control, default bitrates, and marquee pair"

Post-plan note (not a task — informational only)

Deploying this to o2switch requires no migration and no new dependency install beyond what's already there — FFMPEG_PATH is already set per the user. Confirm FFMPEG_PATH points at a real, executable ffmpeg binary on that host before relying on this in production (the existing .env.local mirrors CALIBRE_PATH with a real o2switch path as a reference for what FFMPEG_PATH should look like there).