npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

audiobook-master

v0.3.0

Published

Losslessly bring audiobook MP3s to distributor QC spec (ACX, Author Republic, Findaway Voices, Google Play Books) without re-encoding.

Readme

audiobook-master

Losslessly bring audiobook MP3s to distributor QC spec (ACX/Audible, Author Republic, Findaway Voices/Spotify, Google Play Books) without ever re-encoding. Peak is reduced by rewriting the MP3 global_gain field (mp3gain -g), and edge silence is added by concatenating frames of digital silence (ffmpeg -f concat -c copy).

Installation

Requires Node.js 20 or later (Node >= 20). You also need ffmpeg, ffprobe and mp3gain on your PATH (see Requirements).

Run it without installing:

npx audiobook-master --help

Or install it globally so the audiobook-master command is always available:

npm install -g audiobook-master
audiobook-master --version

The published package contains only the built CLI (dist/cli.js), README.md, LICENSE, CHANGELOG.md and package.json. test/package.test.ts packs the tarball, installs it globally into a temporary prefix and runs the installed binary. CI also runs the packed tarball with npx --yes ./<tarball> --version.

Requirements

  • Node.js 20 or later.
  • ffmpeg and ffprobe (shipped together in the ffmpeg package): used to measure files and to add edge silence with -c copy.
  • mp3gain: used to lower peak by rewriting the MP3 global_gain field (mp3gain -g).
  • Only for fix --limit (or l in the TUI): an ffmpeg build with the alimiter filter and libmp3lame. Standard builds (Homebrew, Debian, Fedora) have both. fix --limit checks for alimiter before reading any file and exits 3 without it; nothing else checks for it.

All three must be on your PATH. audiobook-master never installs anything itself. check, fix, verify and the TUI check for the tools they need before touching any file and exit with code 3 if any are missing. Run audiobook-master doctor to see what is installed. Install the tools with:

| Platform | Command | | --- | --- | | macOS | brew install ffmpeg mp3gain | | Debian, Ubuntu and derivatives (/etc/os-release ID or ID_LIKE contains debian or ubuntu) | sudo apt install ffmpeg mp3gain | | Fedora, RHEL and derivatives (ID or ID_LIKE contains fedora or rhel) | sudo dnf install ffmpeg mp3gain | | Other Linux | Install ffmpeg (includes ffprobe) and mp3gain with your distribution's package manager |

Windows is not covered by these hints.

TUI Usage

Run with no subcommand in a terminal to open the full-screen interface:

cd "path/to/book/MP3s" && npx audiobook-master   # MP3s here: the folder screen is skipped
npx audiobook-master                              # otherwise: browse to the folder
npx audiobook-master ./book                       # open ./book directly

Without [dir], if the current directory holds at least one .mp3, it is used and the folder screen is skipped. The optional [dir] is resolved against the current directory (quotes, \ escapes and ~ are accepted). If it holds at least one .mp3, the folder screen is skipped. If it is a folder without MP3s, the folder browser opens there. If it does not exist (a typo, or a mistyped subcommand such as audiobook-master chek, which arrives as a folder name), the browser opens in the current directory with an error saying why. When stdout is not a terminal, the bare command prints usage to stderr and exits 2 instead of starting the interface.

Screens, in order (a step indicator under the banner shows where you are):

  1. System check runs the same probe as audiobook-master doctor. With ffmpeg, ffprobe and mp3gain all found, press enter to continue. If any is missing, its install command is shown, enter does nothing, and quitting exits with code 3.
  2. Folder is a folder browser, opened in the current directory. It shows the folder's path, a ✔ Use this folder (N MP3s) row when the folder holds MP3s, a .. row, and every visible subfolder with its own MP3 count. ↑/↓ move, enter (or →) opens a subfolder or uses the folder, and ⌫ (or ←) goes up to the parent. Long folders scroll. Press / to type or paste a path instead: surrounding quotes (as left by dragging a folder into the terminal), \ escaped spaces, ~, file:// URLs and bracketed-paste codes are all accepted, and relative paths start from the folder being browsed. A path with MP3s is used at once; a folder without MP3s opens in the browser; a path that does not exist shows an error. esc closes the prompt.
  3. Preset lists every preset audiobook-master presets would list (built-ins plus any custom presets), with its peak ceiling, RMS window, and head and tail silence windows. acx is highlighted first. ↑/↓ move and enter selects. Preset warnings (such as Google Play's signatures.json note) are shown under the list. If the custom preset file is invalid, the error is shown and no preset can be selected.
  4. Master runs the pipeline on the chosen folder with the chosen preset. See The pipeline below.

Global keys, shown in the footer of every screen:

| Key | Action | | --- | --- | | ? | Show or hide the key help. Screen keys are paused while it is open. While the folder path prompt is open, ? is typed as a character. | | q | Quit. Exit code 0, or 3 if the system check found missing tools. While the folder path prompt is open, q is typed as a letter (press esc first). | | ctrl+c | Quit from anywhere, with the same exit codes as q. |

Colours are consistent across screens: green means OK, red means a problem, amber means a warning.

The pipeline

The Master screen runs the same fix and verify commands as the command line and shows their results. The TUI contains no audio logic of its own. A step bar across the top shows six steps:

Scan → Measure → Plan → Fix → Re-check → Verify

Each step is pending (○), running (spinner, with a done/total file count), done (✔, green) or failed (✘, red). The steps update from the commands' progress events.

Scan, Measure and Plan run as soon as the screen opens. This is fix --dry-run, so nothing is written. When they finish, a table shows one row per MP3:

| Column | Meaning | | --- | --- | | FILE | File name. | | DUR | Duration in seconds. | | HEAD / TAIL | Leading and trailing silence in seconds. | | PEAK | The higher of sample peak and true peak, in dBFS. | | RMS | RMS level in dBFS. | | NOISE | Noise floor in dBFS (see check), or — when every window is digital silence. | | VERDICT | PASS, or the checks the file fails now (HEAD, TAIL, PEAK, RMS, NOISE, FORMAT). | | PLAN | The lossless fix: head and tail pads in seconds, and gain in mp3gain steps and dB. — means nothing to do. A conflict shows the steps the peak needs against the steps RMS allows, or, with the limiter on (l), LIMITED re-encode to <ceiling> dBFS (not lossless), then re-plan. |

Row colours: green means the file passes as it is, red means it fails now and the plan fixes it, amber means the plan cannot fully fix it (a peak/RMS conflict, or an unfixable problem such as a noise floor that is too high, or the wrong format), and pink marks a conflicting file the limiter will re-encode (limiter on).

Submission panel. Under the table, a Submission panel judges the book as a whole, exactly as the Submission section of check does: ✔ READY for <preset> (green, or amber when there are advisories) or ✘ NOT READY for <preset> (red), then one line per reason: LIMITED (pink) when the limiter is on and files will be, or after the fix were, re-encoded, FILES when files fail their own checks, each book failure (MIXED_FORMAT, VBR, TOO_LONG, SAMPLE_LENGTH) in red, and each advisory (UNKNOWN_BITRATE_MODE, NO_SAMPLE, NO_CREDITS) in amber. It describes the files as they are now, before any fix; the results show it again for the fixed outputs.

Pipeline keys:

| Key | Action | | --- | --- | | enter | Continue: from the plan to the confirmation, then confirm the fix. | | esc | From the confirmation, go back to the plan. | | p | Go back to the preset picker. The new preset is planned from the measurements already taken, without measuring again. After an in-place fix the files have been rewritten, so they are measured again. Not available while measuring or fixing. | | d | Toggle dry run. With dry run on, confirming runs fix --dry-run: no silence is encoded, no concat or mp3gain runs, and nothing is written. | | i | Toggle in place. With in place on, the originals are overwritten (fix --in-place) instead of writing to <folder>/mastered/. | | l | Toggle the limiter (fix --limit, at the default 1 dB margin). Offered only when a file has a peak/RMS conflict. With it on, the conflicting files (and only those) are re-encoded through a limiter and then fixed losslessly; they lose their lossless status. See Limiting. | | v | After a fix, run verify on the originals against the outputs, using the report the fix wrote. Shows one verdict per file: lossless, lossy, or failed (for example, an output shorter than its original with no trim recorded in the report). Not available after a dry run or an in-place fix, because there is nothing to compare against. | | ? / q | Help and quit, as on every screen. |

The footer labels for d, i and l say what pressing the key will do (turn dry run on, overwrite originals, limit conflicts (lossy)), and the status line under the steps shows the current mode as a badge: WRITE → mastered/ (the default), DRY RUN · nothing written, or IN PLACE · overwrites originals, plus LIMIT · conflicts re-encoded while the limiter is on.

Conflicts. If any file has a peak/RMS conflict (see fix), enter at the plan does not continue. A conflict banner explains why, and the Fix step stays pending. Press l to turn the limiter on (the conflicting files are re-encoded, see Limiting), p to choose a preset under which no file conflicts, or use audiobook-master fix --force on the command line. The TUI never forces a conflict. After a fix with the limiter on, the Re-check panel lists every limited file as ⚠ LIMITED <file>, the Submission panel shows a LIMITED line, and v shows those files as ⚠ LIMITED (not compared) instead of lossless or lossy.

Confirmation. The confirmation shows the destination (<folder>/mastered/, the folder itself for in place, or nothing written for a dry run) and the file counts: total, to change, unchanged and unfixable, plus re-encoded (LIMITED, not lossless) when the limiter is on. enter confirms. In place needs two confirmations: the first enter only shows a final warning that the originals cannot be recovered, and nothing runs until the second enter. Toggling d or i resets the confirmation count.

Results. After the fix, the Fix and Re-check steps complete and the results show the re-check summary (pass and fail counts, the files still failing, and the report path), the Submission panel for the fixed outputs, each file's verify verdict once v has run, and the invalidated artefacts list with one entry per artefact the fix reported. If the fix fails (for example, the output folder cannot be created), the Fix step is marked failed and the error is shown.

Fits the terminal. The screen is always kept shorter than the terminal window, so it updates in place instead of flashing. A long book's file table scrolls in a window that follows the file being processed (↑ n more / ↓ n more show what is off screen), and the results list the failing or lossy files first, then … and n more. The Submission panel's reasons share the table's line budget: they get at most a third of it, and any that do not fit are summed up as … and n more; each reason is cut to one line rather than wrapped. When every output verifies as lossless and there are more than fit, verify shows a single ✔ All N outputs LOSSLESS line. On terminals shorter than 40 lines the banner shrinks to one line. Resizing the window is picked up immediately.

For developers: the interface reads keys from and draws to ctx.tty ({ stdin, stdout }), which defaults to process.stdin and process.stdout. Tests pass createTerminalDouble() from test/support/terminal-double.ts instead. It is a plain-object terminal that takes keystrokes through press(data) and records each frame drawn in frames. Screen-level tests render <App> from src/tui/App.tsx with ink-testing-library and observe its onChange(state) callback ({ screen, dir, preset, help, pipeline }) and onExit(code). pipeline is the pipeline screen's view: phase (planning, planned, confirm, fixing, results or error), steps (each step's pending/running/done/failed), rows (one per MP3, with its measurement, failures and plan), book (the Submission verdict, { ready, failures, advisories }, or null before measuring), dryRun, inPlace, confirmations, conflictBanner, and the fix and verify outcomes and artefacts.

Commands

The headless subcommands and their flags will be documented here.

check

audiobook-master check <paths...> [--preset <id>] [--preset-file <path>] [overrides] [--jobs <n>] [--json]

Measures every MP3 and reports PASS or FAIL against a preset. It changes nothing: no file is written, renamed or touched.

<paths...> are MP3 files and/or directories. A directory contributes the *.mp3 files directly inside it (case-insensitive, so .MP3 counts; subdirectories are not scanned), sorted by name. A file path is checked as given. Output follows input order.

| Flag | Meaning | | --- | --- | | --preset <id> | Preset to judge against (default acx). See Presets. | | --preset-file <path> | Custom presets file. See Custom Presets. | | --peak-ceiling <n> ... --sample-rate <n> | Per-run numeric overrides. See Per-run overrides. | | --jobs <n> | Files measured in parallel (positive integer; default min(4, CPU count)). Does not change output order. It limits files, not processes: each file runs up to 3 ffmpeg/ffprobe processes at once, so --jobs 4 can mean 12 processes. | | --json | Print JSON (see JSON Output) instead of the table. |

For each file it runs ffprobe (duration, sample rate, channels, bitrate, cover art), ffmpeg volumedetect + ebur128 + windowed astats (sample peak, true peak, mean RMS and noise floor, all from one decode) and ffmpeg silencedetect at the preset's silenceThresh over the first min(15, duration) seconds (head silence) and the last min(15, duration) seconds (tail silence). It also reads the file directly (not through a tool) for the LAME/Xing header: the 10-byte ID3v2 header says where the audio starts (past any tags and embedded cover art, however large), and the first 4 KB of audio from there is searched. Info means constant bitrate (CBR), Xing variable (VBR), and neither means unknown.

The table has one row per file:

| Column | Meaning | | --- | --- | | FILE | File name | | DUR | Duration, seconds | | HEAD | Leading silence, seconds | | TAIL | Trailing silence, seconds | | PEAK | The higher of sample peak and true peak, dBFS | | RMS | Mean RMS level, dBFS | | NOISE | Noise floor, dBFS, or — when every window is digital silence | | VERDICT | PASS, or FAIL followed by the failed checks, e.g. FAIL HEAD,PEAK |

A file fails HEAD/TAIL when its silence is outside the preset's min..max, PEAK when the higher of sample and true peak is above peakCeiling, RMS when outside rmsFloor..rmsCeiling, NOISE when the preset sets noiseFloorMax and the noise floor is above it, and FORMAT when its bitrate is below minBitrateKbps or its sample rate differs from sampleRate. After the rows comes ALL PASS or <n> file(s) FAIL.

Submission. The table ends with a Submission section that judges the book as a whole: Submission: READY for <preset> or Submission: NOT READY for <preset>, then one line per book failure (✘) and advisory (⚠), each with its code and a message naming the files. The book is ready when no file fails and there are no book failures; advisories never fail. Distributors reject whole submissions for these reasons, so check the full set of files you will upload:

| Code | Kind | When | | --- | --- | --- | | MIXED_FORMAT | failure | Sample rate, channel count or bitrate differs across the files. One issue per property; it names the files that differ from the most common value. Always runs, whatever the preset. | | VBR | failure | The preset's book.requireCbr is set and a file is VBR (cbr is false). | | UNKNOWN_BITRATE_MODE | advisory | The preset's book.requireCbr is set and a file's bitrate mode is unknown (cbr is null: no Info/Xing tag, as with an MP3 written without one). Lists those files; the CBR rule could not be checked for them, so confirm them yourself. Never fails and never affects ready. | | TOO_LONG | failure | A file is longer than book.maxFileMinutes (acx: 120 minutes). | | SAMPLE_LENGTH | failure | A file whose name matches book.sample.pattern (case-insensitive) is shorter than minSeconds or longer than maxSeconds (acx: 1 to 5 minutes). | | NO_SAMPLE | advisory | No file name matches book.sample.pattern. | | NO_CREDITS | advisory | No file name matches book.credits.pattern. |

Only the rules the preset's book sets are checked (see Presets). These checks only report: fix never rewrites files to fix them, and they do not listen for what the credits say.

Noise floor. The file is split into 0.5 s windows (half the sample rate in samples) and the RMS level of each is measured. The noise floor is the quietest window, ignoring digital silence: windows at or below -90 dBFS (or -inf), such as padding fix added or a fully silent stretch, never count. A file that is digital silence throughout has no noise floor (null, shown as —) and never fails NOISE. Room tone in the pauses is what this measures, so a file whose only quiet parts are digital silence reports the level of its quietest programme window. A preset without noiseFloorMax never reports NOISE. The noise floor cannot be fixed losslessly (gain moves noise and speech together), so it is reported only.

Exit codes:

| Code | When | | --- | --- | | 0 | Every file passes and the book is ready (advisories allowed) | | 1 | At least one file fails, or there is a book failure even though every file passes | | 2 | Usage error: a missing argument or unknown option, unknown preset, invalid preset file or override, bad --jobs, a path that does not exist or cannot be read (the real error, e.g. EACCES, is reported), no MP3 found, or a file that cannot be measured (tool error, unreadable output, or a duration of 0); the message names the file | | 3 | ffmpeg or ffprobe is missing (checked before any file is read) |

fix

audiobook-master fix <paths...> [--preset <id>] [--preset-file <path>] [overrides] [--out <dir> | --in-place] [--dry-run] [--force | --limit [--limit-margin <dB>]] [--jobs <n>] [--json]

Brings MP3s to a preset's spec without re-encoding them (the one opt-in exception is --limit). Your MP3 is often your only master, so by default fix never touches it: corrected copies go to a separate folder.

<paths...> are collected exactly as for check. For each file fix measures it, plans a correction, applies it, writes a report, and finally runs check on the outputs.

Two corrections exist, and both are lossless:

  • Edge silence. A head or tail shorter than the preset minimum is padded up to the preset target. The padding is frames of digital silence encoded at the source's sample rate, channel count and bitrate (with no Xing header and no tags), joined to the source's untouched frames with ffmpeg -f concat -c copy. A head or tail longer than the preset maximum is trimmed instead (an edge is never both padded and trimmed): the source's entry in the same concat list gets inpoint <seconds> (head) and/or outpoint <duration - seconds> (tail), and the stream copy drops whole frames outside them. All padding and trimming happens in one concat per file, even for a file that only needs trimming. The source is also given as a second input so its ID3 tags and cover art are mapped back onto the output.
  • Peak and loudness. A peak above the ceiling, or an RMS above the preset's RMS ceiling, is lowered with mp3gain -g -<steps>, which rewrites each frame's global_gain field. One step is 1.505 dB. The plan takes the steps the peak needs and the steps RMS needs to get under its ceiling (after the dilution from any added silence, which lowers whole-file RMS a little on its own), whichever is larger. A file whose RMS is under the floor is never raised. Gain is applied after padding, so the undo information mp3gain stores in the file survives; mp3gain -u <file> on an output reverses the gain. fix never uses mp3gain -r, -a or -s.

Trimming precision and safety. A stream copy can only cut between MP3 frames (1152 samples at 32 kHz and above, 576 below; 26 ms at 44.1 kHz), so trimming is frame-accurate, not sample-accurate. The plan cuts floor((edge - target) / frame) - 1 whole frames: one frame short of the preset target, so the planned edge is left between one and two frames above the target. That margin, plus the target silence itself, keeps every cut inside silence, never in speech. With ffmpeg 8 the actual cut was measured to land up to about 3 frames from the planned one (it can keep or drop slightly more), which still leaves the edge well inside the preset window; the re-check measures the real result. The first frame after a cut may decode slightly differently (the MP3 bit reservoir), but it lies in the silence, outside anything verify compares. Edges are measured over at most the first and last 15 s, so a longer edge is trimmed by at most that much per run; run fix again on the output to trim the rest.

Without --limit, the only thing fix ever encodes is the silence itself (from anullsrc). A user file is only ever read with -c copy or decoded for measurement.

Silence that is still over the preset maximum after the safe trim (HEAD_LONG, TAIL_LONG; the trim always leaves one to two frames above the target, so this only happens with a custom preset whose maximum is less than two frames above its target), a bitrate or sample rate the preset rejects (FORMAT), or a noise floor above the preset's noiseFloorMax (NOISE), cannot be fixed losslessly. Such files are listed under unfixable and are copied to the output folder unchanged (or left alone with --in-place) unless they also have something fixable, which is then fixed. A file with nothing to fix is copied unchanged too.

| Flag | Meaning | | --- | --- | | --preset <id>, --preset-file <path>, overrides | As for check; see Presets and Per-run overrides. | | --out <dir> | Output folder, relative to the current directory. Created if missing. Default: mastered/ inside the folder of the first input (for file inputs, the folder of the first file). Exits 2 if an output would land on its own source, or if two inputs share a file name. | | --in-place | Replace the input files instead. Only files that need a change are rewritten, and each is replaced only once every step for it has succeeded. Cannot be combined with --out (exit 2). | | --dry-run | Measure and plan only; print the plans and write nothing. | | --force | On a peak/RMS conflict, apply the minimum gain fix (the steps that clear the peak and the RMS ceiling) anyway (RMS will then fail the re-check). Cannot be combined with --limit (exit 2). | | --limit | On a peak/RMS conflict, re-encode only the conflicting files through a limiter, then fix them losslessly. Lossy: see Limiting. Every other file is fixed exactly as without it. | | --limit-margin <dB> | How far below the preset's peak ceiling the limiter's ceiling sits (default 1). Must be positive, and small enough that the retry ceiling (peakCeiling - 2 × margin) stays at or above -24.1 dBFS, alimiter's lowest; otherwise exit 2. | | --jobs <n> | Files processed in parallel (default min(4, CPU count)). It limits files, not processes: each file may run up to 3 ffmpeg/ffprobe processes at once. | | --json | Print JSON instead of text. |

Peak/RMS conflict. Gain lowers peak and RMS by the same amount, and fix lowers it both for a peak above the ceiling and for an RMS above the RMS ceiling. When clearing the peak ceiling (or the RMS ceiling) needs more steps than RMS can lose before it drops under the preset's RMS floor (padding with silence lowers RMS a little too, and trimming silence raises it), no lossless gain satisfies both: the file's peaks are too high for its average level. Fixing that needs a limiter or compressor, which means re-encoding, and fix never offers anything lossy. Without --force it exits 2, explains which files conflict and writes nothing (the output folder is not even created). With --force it applies the minimum steps that clear the peak and the RMS ceiling. With --limit it limits the conflicting files (below).

Limiting (opt-in, lossy)

--limit (or l in the TUI) is the explicit escape hatch for a peak/RMS conflict. It is off by default and lasts for one run only. Without it, nothing below happens and fix stays 100% lossless.

For each file whose plan is a conflict, and no other file, fix:

  1. Re-encodes it with ffmpeg -af alimiter=limit=<ceiling>:attack=5:release=50:level=false:latency=true -c:a libmp3lame -b:a <bitrate>k -ar <source rate> -ac <source channels> -map_metadata 0 -id3v2_version 3 (cover art is stream-copied). The ceiling is the preset's peakCeiling minus --limit-margin (default 1 dB), which leaves room for the inter-sample peaks the MP3 encode adds back: ACX's -3 dBFS becomes a -4 dBFS limiter ceiling. Auto make-up gain is off (level=false), so the limiter only ever turns peaks down. The output is CBR at the preset's minBitrateKbps or the source's bitrate, whichever is higher.
  2. Measures the limited file again. If its peak (sample or true) is still above the preset ceiling, it retries once from the original, one more margin lower (-5 dBFS for ACX). If that still fails, the file fails with still peaks at <n> dBFS after 2 limiter passes: nothing is written for it, the other files carry on, and fix exits 2.
  3. Plans the limited file again and fixes it like any other: head and tail padding or trims, and gain steps if needed. It then goes through the re-check with the rest.

--dry-run --limit measures and plans only: it lists each conflicting file with the ceiling and bitrate it would use, and runs no limiter. --in-place replaces a limited file's original like any other fix (only after every step succeeded). The default output to mastered/ is unchanged.

The trade-off. A limited file is decoded and re-encoded, so it loses its lossless status: it is one MP3 generation further from your master, and mp3gain -u cannot undo it. Every place that reports on it says so. The text output has a LIMITED - re-encoded ... line per file, the JSON has a limited list, the report entry has a limited field, the TUI marks it LIMITED in pink, and verify labels it LIMITED instead of comparing it. The re-encode itself also lowers RMS slightly (about 0.3 dB on a test tone), so a file whose RMS sits right on the preset floor can come out just under it. The re-check reports that as an RMS failure.

When to use it. Use it when a few chapters have isolated transients (a plosive, a door, a laugh) that make the peak too high for the file's average level, and re-recording or re-mastering them from the source is not an option. If many files conflict, the master itself is too dynamic, and a proper limiter or compressor pass in your DAW, followed by a fresh export, will sound better than limiting the MP3s.

Each output is staged in a hidden .audiobook-master-* folder beside its destination and moved into place only when every step succeeded. If a step fails, nothing is left at that file's destination, the other files are still processed, and fix exits 2 naming the file. File-system failures are reported the same way: if the output folder cannot be created, an unchanged file cannot be copied to its destination, or the report cannot be written, fix exits 2 with an error naming the path.

After a run, fix lists the packaging files in the input folders that the change makes stale (see Invalidated Artefacts); it never modifies them.

With --json the output is:

{
  "preset": "acx",
  "outDir": "/books/mastered",
  "plans": [{ "file": "/books/ch01.mp3", "measurement": { "...": "as in check" }, "plan": { "headPad": 0.75, "tailPad": 0, "headTrim": 0, "tailTrim": 6.948571, "steps": 2, "minSteps": 2, "maxSteps": 3, "peakAfter": -4.1, "rmsAfter": -21.3, "headAfter": 0.75, "tailAfter": 2.051429, "conflict": false, "unfixable": [] } }],
  "unfixable": [],
  "report": "/books/mastered/audiobook-master.report.json",
  "artefacts": [],
  "recheck": { "...": "the check JSON for the outputs" }
}

outDir is null with --in-place. A conflict without --force or --limit prints {"error": "conflict", "preset", "conflicts": [files], "plans"}; --dry-run prints {"preset", "dryRun": true, "plans"} (plus "limit": {"marginDb", "files": [{"file", "ceilingDbfs", "bitrateKbps"}]} with --limit); a failed file adds error and errors and omits recheck. With --limit, the output also has "limited": [{"file", "ceilingDbfs", "bitrateKbps", "attempts"}], one entry per file that was re-encoded ([] when none conflicted). plans always holds the plans made before limiting. An alimiter that is missing prints {"error": "missing-filter", "missing": ["alimiter"], "installHint"}.

Exit codes:

| Code | When | | --- | --- | | 0 | Every output passes the re-check and the outputs are ready for submission (also: --dry-run with no conflict left unhandled by --force or --limit) | | 1 | At least one output fails the re-check (for example an unfixable file, or a forced conflict), or the re-check finds a book failure (for example VBR files, which fix keeps as they are) | | 2 | Usage error (as for check, plus --out with --in-place, an output that would overwrite its source, two inputs with the same name, --limit with --force, or a bad --limit-margin), a peak/RMS conflict without --force or --limit, a file whose fix failed (including a file still over the peak ceiling after both limiter passes), or an output folder, copy or report that could not be written | | 3 | ffmpeg, ffprobe or mp3gain is missing, or --limit was given and ffmpeg has no alimiter filter (checked before any file is read) |

verify

audiobook-master verify <original> <processed> [--steps n] [--seconds 60] [--json]

Proves that a processed MP3 differs from its original only by the added or trimmed edge silence and the exact mp3gain factor 2^(-steps/4), i.e. that nothing was re-encoded. Both files are decoded with ffmpeg to 32-bit float PCM (ffmpeg -v error -y -i <file> -f f32le -acodec pcm_f32le <raw>). The processed audio is aligned to the original, and the original, scaled by the gain factor, is compared with it over a window of --seconds seconds (default 60) taken from the loudest part of the original. The sample rate comes from ffprobe on the original. Decodes go to <index>_<basename>.raw in a fresh temporary directory, so two inputs with the same name never collide, and the directory is deleted afterwards.

Two modes:

  • Two files: verify book/ch01.mp3 mastered/ch01.mp3 compares that one pair.
  • Two directories: verify book mastered pairs every *.mp3 present in both directories by file name and compares each pair. Files present in only one directory are skipped. If no file is present in both, it exits 2.

--steps n gives the mp3gain steps that were applied (a non-negative integer) and is used for every pair. Without it, steps are looked up in the audiobook-master.report.json that fix writes (see Report File): in the processed file's directory in file mode, or in the processed directory in directory mode. Each pair uses the entry whose output has the same file name as its processed file. With no --steps, or with a report that is missing or has no entry for a file, verify exits 2 before decoding anything. A report that exists but is unreadable or fails the schema (for example a silenceThresh above -40 dBFS) always exits 2 before decoding, even with --steps, naming the report and the offending field.

Trimmed outputs need the report. A fix that trimmed an edge makes the output shorter than its original. verify reads headTrim/tailTrim from the same report entry (also when --steps is given) and, for a trimmed head, searches for the alignment at a negative offset around the trim (within a few frames, because a stream-copy cut lands on a frame boundary near, not exactly at, the planned point). The comparison window is taken from the loud part of the original that is present in both decodes, so the first frame after a cut is never compared.

The report's edits are checked, not trusted. Whenever the report has an entry for the pair, verify reads all four of headPad, tailPad, headTrim and tailTrim (and silenceThresh) and requires:

  • The length change matches the report. The processed decode must be longer by headPad + tailPad - headTrim - tailTrim (in samples), within 8 frames (9216 samples) for the slack of a stream-copy cut and the encoder delay of the added silence. A truncated output, such as a tail cut far deeper than the recorded trim, fails here.
  • Everything cut was silent. After alignment, verify knows how much of the original is really missing at each end, which can be up to the length check's slack more than the recorded headTrim/tailTrim. Every sample of the original that is missing from the output must be silent: each 50 ms block of it (blocks overlap, a quarter block apart) must have an RMS below the entry's silenceThresh (the preset's threshold, which fix records), or -45 dBFS when the entry has none (older reports). The threshold is never louder than -40 dBFS: the report schema rejects a louder one, and verify clamps to -40 as well. Judging overlapping blocks separately means a short word inside a long trim cannot be averaged away or split between two blocks. The threshold used is shown for every pair with a report entry: a cut regions must be below <n> dBFS line in the text output and silenceDbfs in the JSON. A cut that removed speech fails with trimmed region contains audio.
  • There is something to compare. A comparison window shorter than 400 samples (an empty output, for example) fails rather than passing on nothing.

Each of these failures makes the pair lossy (lossless: false, exit 1), with the reason in error. Without a report entry (--steps and no report), only padding can be checked: a longer output is aligned as before. A processed file that is shorter while no report entry records a trim (for example a trimmed output copied away from its report, verified with --steps only) exits 2 with an error saying the trim is unknown and to keep the report from fix beside the processed file.

Without --json it prints, for each pair, the two paths, the steps and padding found, the difference figures, and the verdict LOSSLESS (float rounding only) or LOSSY - a re-encode got in (or LOSSY - <reason> when a report check fails). With --json it prints { "pairs": [...] } with one entry per pair:

| Field | Type | Meaning | | --- | --- | --- | | original | string | Absolute path of the original file. | | processed | string | Absolute path of the processed file. | | steps | number | mp3gain steps the comparison assumed. | | totalPadSamples | number | Samples the processed decode is longer by (negative when edges were trimmed). | | headPadSamples | number | Head offset found by alignment: samples added at the start, or minus the samples cut from a trimmed head. | | tailPadSamples | number | totalPadSamples - headPadSamples: samples added at the end, negative when the tail was trimmed. | | windowStartSeconds | number | Where in the original the comparison window starts. | | maxDiffDbfs | number | Largest sample difference in the window, in dBFS. | | rmsDiffDbfs | number | RMS of the difference in the window, in dBFS. | | snrDb | number | Signal-to-difference ratio in the window, in dB. | | lossless | boolean | true when the RMS difference is below 1e-6 (float rounding only). | | silenceDbfs | number | The threshold cut regions were judged by (the entry's silenceThresh, else -45, never above -40). Present only when a report entry gave the edits. |

A pair whose processed decode is shorter than its original with no trim recorded cannot be padding plus gain; it has { "original", "processed", "error": "processed shorter than original: ..." } (the message says the trim is unknown and to pass the report) instead of the comparison fields. A pair that fails the report checks above has { "original", "processed", "lossless": false, "error": "..." } (for example "trimmed region contains audio").

Limited files. A pair whose report entry has limited (the file was re-encoded by fix --limit) cannot be lossless by design, so verify neither decodes nor compares it. It prints LIMITED - re-encoded by fix --limit (ceiling <n> dBFS, <n>k CBR); not compared, and in the JSON the pair is { "original", "processed", "limited": { "ceilingDbfs", "bitrateKbps", "attempts" } }. A limited pair does not affect the exit code: every other pair must still be lossless for exit 0. The report entry is the record of what was done to it.

{ "pairs": [
  { "original": "/books/b1/ch01.mp3", "processed": "/books/b1/mastered/ch01.mp3", "totalPadSamples": 5600,
    "headPadSamples": 4000, "tailPadSamples": 1600, "steps": 2, "windowStartSeconds": 1,
    "maxDiffDbfs": -120.7, "rmsDiffDbfs": -143.3, "snrDb": 125.3, "lossless": true }
] }

Exit codes: 0 when every pair is lossless (or limited, as recorded in the report), 1 when any pair is lossy (including a length change the report does not explain or a trimmed region that was not silent), 2 when any processed file is shorter than its original and no report entry records a trim, a file cannot be decoded, or the arguments are wrong (a file paired with a directory, a missing path, a bad --steps or --seconds, no steps available), and 3 when ffmpeg or ffprobe is missing (checked before any decode).

presets

audiobook-master presets [--preset-file <path>] [--json]

Lists every available preset: the four built-ins followed by any custom presets (see Custom Presets). Without --json it prints a table of id, name, peak ceiling, RMS window, and head/tail silence windows (min..max (target)).

| Flag | Meaning | | --- | --- | | --preset-file <path> | Load custom presets from this file instead of the default search locations. | | --json | Print the full Preset[] array as JSON. |

Exits 0 on success, or 2 if the custom preset file is unreadable, fails the schema, reuses an existing id, or breaks an invariant (the error names the offending field or rule).

doctor

audiobook-master doctor [--json]

Runs ffmpeg -version, ffprobe -version and mp3gain -v and reports whether each tool is installed. A tool counts as not found when it cannot be launched (exit code 127) or when its output has no <tool> version <x> line. doctor only reports. It never installs anything.

Without --json it prints one line per tool, followed by the install command if anything is missing. With --json it prints an array with one entry per tool, in the order ffmpeg, ffprobe, mp3gain:

| Field | Type | Meaning | | --- | --- | --- | | tool | "ffmpeg" | "ffprobe" | "mp3gain" | The tool checked. | | found | boolean | Whether the tool ran and reported a version. | | version | string | null | The version string it reported (e.g. "8.0.1"), or null when not found. | | installHint | string | The install command for this platform (see Requirements). |

[
  { "tool": "ffmpeg", "found": true, "version": "8.0.1", "installHint": "brew install ffmpeg mp3gain" },
  { "tool": "ffprobe", "found": true, "version": "8.0.1", "installHint": "brew install ffmpeg mp3gain" },
  { "tool": "mp3gain", "found": false, "version": null, "installHint": "brew install ffmpeg mp3gain" }
]

Exit codes: 0 when all three tools are found, 3 when any is missing.

When check, fix or verify finds a required tool missing, it exits 3 and with --json prints {"error": "missing-tools", "missing": [...], "installHint": "..."}.

Presets

A preset encodes one distributor's QC spec. Levels are in dBFS, silence windows in seconds. check and fix use acx unless --preset <id> is given.

| id | Name | Peak ceiling | RMS | Head silence (target) | Tail silence (target) | Warnings | | --- | --- | --- | --- | --- | --- | --- | | acx | ACX / Audible | -3.0 | -23 to -18 | 0.5 to 1.0 (0.75) | 1.0 to 5.0 (2.0) | none | | author-republic | Author Republic | -1.0 | -23 to -18 | 1.0 to 5.0 (2.0) | 1.0 to 5.0 (2.0) | none | | findaway | Findaway Voices / Spotify | -3.0 | -23 to -18 | 0.5 to 1.0 (0.75) | 1.0 to 5.0 (2.0) | none | | google-play | Google Play Books | -3.0 | -23 to -18 | 1.0 to 5.0 (2.0) | 1.0 to 5.0 (2.0) | signatures.json in a Google Play Books LDF package is permanently voided by any audio edit and cannot be regenerated |

All built-ins also share: silence threshold -45 dB, preferred mp3gain step headroom 2, minimum bitrate 192 kbps, sample rate 44100 Hz, and a per-file noise floor maximum (noiseFloorMax) of -60 dBFS (see check). Findaway mirrors ACX.

Each built-in also carries book-level rules (book), which judge the book as a whole rather than one file:

| id | Max file length | CBR required | Sample file (name pattern, length) | Credits file (name pattern) | | --- | --- | --- | --- | --- | | acx | 120 min | yes | sample\|retail\|preview, 60 to 300 s | credit\|opening\|closing | | author-republic | none | yes | none | none | | findaway | none | yes | none | none | | google-play | none | yes | none | none |

check (and the fix re-check) report these in the Submission section. The cross-file consistency check (MIXED_FORMAT) runs for every preset, including a custom one without book. These values come from distributor documentation at the time of writing. Distributors change their specs, so confirm every value against the distributor's current docs before you submit. If a value has changed, override it per run or define a custom preset.

Custom Presets

Custom presets are added to the built-ins. The first file that exists is used, searched in this order:

  1. The path given with --preset-file <path> (relative paths resolve against the current directory). If that file does not exist, the command exits 2 naming the path; it does not fall back to the other locations.
  2. ./audiobook-master.presets.json in the current directory.
  3. $XDG_CONFIG_HOME/audiobook-master/presets.json, or $HOME/.config/audiobook-master/presets.json when XDG_CONFIG_HOME is unset or empty.

The file holds a presets array. Every field is required except warnings (defaults to []), noiseFloorMax and book:

{
  "presets": [
    {
      "id": "my-dist",
      "name": "My Distributor",
      "peakCeiling": -2,
      "rmsFloor": -23,
      "rmsCeiling": -18,
      "headMin": 0.5,
      "headMax": 2,
      "headTarget": 1,
      "tailMin": 1,
      "tailMax": 4,
      "tailTarget": 2,
      "silenceThresh": -45,
      "minBitrateKbps": 192,
      "sampleRate": 44100,
      "preferSteps": 2,
      "warnings": [],
      "noiseFloorMax": -60,
      "book": {
        "maxFileMinutes": 120,
        "requireCbr": true,
        "sample": { "pattern": "sample|retail|preview", "minSeconds": 60, "maxSeconds": 300 },
        "credits": { "pattern": "credit|opening|closing" }
      }
    }
  ]
}

The optional fields:

| Field | Meaning | | --- | --- | | noiseFloorMax | Highest allowed noise floor, dBFS. When absent there is no noise-floor check. | | book | Book-level rules. When absent only the cross-file consistency checks apply. Every key inside it is optional. | | book.maxFileMinutes | Longest allowed file, minutes. | | book.requireCbr | true when every file must be constant bitrate. | | book.sample | { pattern, minSeconds, maxSeconds }: the retail sample is the file whose name matches pattern, and its length must be within minSeconds..maxSeconds. | | book.credits | { pattern }: the opening/closing credits files are those whose names match pattern. |

pattern values are JavaScript regular expressions. One that does not compile (for example "(") is a schema error naming the field, such as presets.0.book.sample.pattern.

A file is rejected (exit 2) when a field has the wrong type or a pattern is not a valid regular expression, when an id repeats a built-in or earlier id, or when an invariant fails: headMin <= headMax, tailMin <= tailMax, rmsFloor <= rmsCeiling, headTarget within headMin..headMax, tailTarget within tailMin..tailMax, silenceThresh <= -40 (dBFS; a louder threshold would let verify treat speech as silence, so the schema names silenceThresh when it is above -40).

Per-run overrides

check and fix accept --preset <id>, --preset-file <path>, and one numeric override flag per field. Overrides apply on top of the selected preset, and the invariants are checked again afterwards (so --silence-thresh -10 exits 2: it must be -40 or below).

| Flag | Field | | --- | --- | | --peak-ceiling <n> | peakCeiling | | --rms-floor <n> | rmsFloor | | --rms-ceiling <n> | rmsCeiling | | --head-min <n> | headMin | | --head-max <n> | headMax | | --head-target <n> | headTarget | | --tail-min <n> | tailMin | | --tail-max <n> | tailMax | | --tail-target <n> | tailTarget | | --prefer-steps <n> | preferSteps | | --silence-thresh <n> | silenceThresh | | --min-bitrate <n> | minBitrateKbps | | --sample-rate <n> | sampleRate |

JSON Output

With --json, a command prints one JSON document on stdout instead of its table. Shapes for the remaining commands will be documented here.

check

{
  "preset": "acx",
  "files": [
    {
      "file": "/books/ch01.mp3",
      "duration": 20,
      "head": 0.750023,
      "tail": 2,
      "peak": -2.1,
      "truePeak": -1.8,
      "rms": -20.8,
      "bitrateKbps": 192,
      "sampleRate": 44100,
      "channels": 1,
      "hasCover": false,
      "noiseFloor": -64.8,
      "cbr": null,
      "failures": ["PEAK"]
    }
  ],
  "summary": { "total": 1, "passed": 0, "failed": 1 }
}

file is the absolute path. Times are seconds; peak, truePeak and rms are dBFS, and are null for digital silence (-inf dB). noiseFloor is the quietest 0.5 s window that is not digital silence (above -90 dBFS), in dBFS, and is null when every window is digital silence (see check). cbr is true for constant bitrate (an Info tag in the first 4 KB of audio after any ID3 tag), false for variable (Xing), and null when neither tag is found or the file cannot be read. failures is a subset of HEAD, TAIL, PEAK, RMS, NOISE, FORMAT in that order, and is empty for a passing file. files is in input order. On an exit-2 error the output is {"error": "<message>"}; on exit 3 it is the missing-tools object described under doctor. check always adds a top-level book object, { "ready": boolean, "failures": [Issue], "advisories": [Issue] } with Issue = { "code", "message", "files": [paths] }: the Submission verdict. ready is true when no file fails and failures is empty. code is one of the codes in the Submission table; files lists the absolute paths concerned (empty for NO_SAMPLE and NO_CREDITS). For example:

"book": {
  "ready": false,
  "failures": [{ "code": "VBR", "message": "ACX / Audible requires CBR; VBR: ch02.mp3", "files": ["/books/ch02.mp3"] }],
  "advisories": [{ "code": "NO_SAMPLE", "message": "no retail sample file (name matching /sample|retail|preview/i)", "files": [] }]
}

book is optional in the schema so that JSON written before it existed still validates. The zod schema is checkSchema in src/core/checkSchema.ts.

Exit Codes

Every command, and the TUI, uses the same four codes:

| Code | Meaning | | --- | --- | | 0 | Success: every file passes and the book is ready (check, fix re-check), every pair is lossless or limited (verify), all tools are found (doctor), or the TUI was quit normally. | | 1 | A QC result failed: a file fails the check or the book has a submission failure (check, the fix re-check), or a pair is lossy (verify, including a length change the report does not explain or a trimmed region that was not silent). | | 2 | Nothing usable was produced: a usage or input error (bad option, unknown preset, invalid preset file, missing path, no MP3s, unreadable or unmeasurable file, a file or folder that could not be written), a peak/RMS conflict without --force (fix), a failed file (fix), or a processed file shorter than its original with no trim recorded in the report (verify). The bare command also exits 2 when stdout is not a terminal. | | 3 | ffmpeg, ffprobe or mp3gain is missing, or fix --limit found no alimiter filter in ffmpeg. This is checked before any file is read. In the TUI, quitting after the system check found missing tools exits 3. |

Each command's section lists exactly which cases give which code.

Report File

Every fix run that writes files also writes audiobook-master.report.json to the output folder (with --in-place, to the folder of the first input). It records what was done to each processed file, one entry per file, including files copied unchanged (all zeros). verify reads steps, the pads and the trims from it; a trimmed output cannot be verified without it.

{
  "version": 1,
  "preset": "acx",
  "entries": [
    {
      "source": "/books/ch01.mp3",
      "output": "/books/mastered/ch01.mp3",
      "preset": "acx",
      "headPad": 0.75,
      "tailPad": 0,
      "headTrim": 0,
      "tailTrim": 6.948571,
      "steps": 2,
      "silenceThresh": -45
    }
  ]
}

| Field | Meaning | | --- | --- | | version | Always 1. | | preset | Preset id the run used. | | entries[].source / output | Absolute paths of the input and the written file (the same path with --in-place). | | entries[].headPad / tailPad | Seconds of digital silence added at the start / end. verify checks the output's length change against the pads and trims. | | entries[].headTrim / tailTrim | Seconds of silence cut from the start / end (whole MP3 frames, as planned; the stream copy cuts at the nearest frame boundary). verify needs them to check a trimmed, shorter output. Optional: a missing value reads as 0, so older report files stay valid (still version: 1). Never negative. | | entries[].steps | mp3gain steps the gain was lowered by (1.505 dB each). | | entries[].limited | Present only when fix --limit re-encoded the file: { "ceilingDbfs", "bitrateKbps", "attempts" } (the limiter ceiling that passed, the CBR bitrate, and 1 or 2 passes). The output is not lossless; verify labels it LIMITED and does not compare it. The pads, trims and steps beside it describe the lossless fix applied to the limited file. Optional, so older reports stay valid (still version: 1). | | entries[].silenceThresh | The preset's silenceThresh (dBFS) for the run, at most -40 (a louder value makes the report invalid). verify requires every 50 ms block of the audio cut from the original to be below it. Optional: older reports without it are verified at -45 dBFS (still version: 1). |

A file whose fix failed has no entry. A later run into the same folder replaces the file. The zod schema is reportFileSchema in src/types.ts.

Invalidated Artefacts

Changing the audio invalidates anything that measured or signed it. After fix, the tool lists the packaging files in the input folder (top level only, names matched case-insensitively) that are now stale. It never reads, modifies, or regenerates these files; updating them is up to you.

| File | Kind | Effect | | --- | --- | --- | | signatures.json | signatures | Permanently void, cannot be regenerated (Google Play Books LDF signatures are ECDSA-signed). | | publication.json, manifest.json | manifest | Durations must be updated. | | *.cue | cue | Chapter offsets shift by the head pad. | | *.md5, *.sha1, *.sha256, SHA256SUMS, MD5SUMS | checksum | Checksums must be recomputed. |

Any warnings carried by the chosen preset (for example the Google Play Books preset's LDF signature warning) are always listed too, as preset-warning entries with no file.

Development

Requires Node.js 20 or later.

npm ci                # install exact dependencies from package-lock.json
npm test              # run the test suite (vitest)
npm run coverage      # run tests with coverage; fails if any file in src/ is below 80% functions/statements/branches/lines
npm run typecheck     # tsc --noEmit
npm run lint          # eslint
npm run build         # bundle src/cli.ts to dist/cli.js (tsup)

The end-to-end suite runs the real tools, so it needs ffmpeg (with ffprobe) and mp3gain on your PATH:

  • macOS: brew install ffmpeg mp3gain
  • Debian/Ubuntu: sudo apt-get install ffmpeg mp3gain
npx vitest run test/e2e   # run only the end-to-end suite (a few seconds)

test/e2e/lossless.test.ts builds the CLI into node_modules/.cache/audiobook-master-e2e/, generates a 20 s synthetic-speech MP3 (192k CBR, peaking near -0.3 dBFS, no edge silence, ID3 title, cover art) in a temp folder with test/e2e/generate.ts, then runs the real fix, check and verify against it. It proves that fix --preset acx passes the re-check, that verify reads an rms difference below -140 dBFS (a libmp3lame re-encode of the output fails it), that the title tag and cover survive, that mp3gain -u restores the original peak, and that re-running fix on the output plans nothing. When ffmpeg, ffprobe or mp3gain is not on your PATH, the suite is reported as skipped, not passed. CI requires it to run.

Testing

Tests are contract tests: data in, data out, with no mocking library. There are two test doubles, both plain objects at true external boundaries: the process runner (ctx.run) and, for the TUI, the terminal (ctx.tty). Every command receives a Ctx whose run(cmd, args) is the single place a process is spawned; in tests, createRunDouble(calls) from test/support/run-double.ts is a plain object that replays recorded ffprobe/ffmpeg/mp3gain results and records every call it receives in .calls as { cmd, args }, in order. A call matches a recorded entry when cmd is equal and each argsInclude string is a substring of a later argument than the one before it (so the order is significant). The first matching entry wins. A call with no match returns { code: 1, stdout: '', stderr: 'no fixture for <cmd> <args>' }.

Recorded calls live in JSON fixtures under test/fixtures/. loadFixture(name) from test/support/fixtures.ts reads test/fixtures/<name>.json and validates it with zod when it loads. Invalid fixtures throw an error that names the failing path (for example calls.0.result.code). A fixture has these fields:

| Field | Type | Meaning | | --- | --- | --- | | name | string | Fixture identifier. | | description | string | What the recording represents. | | calls[].match.cmd | string | Exact command name, e.g. ffprobe. | | calls[].match.argsInclude | string[] | Ordered argument substrings that must all appear. | | calls[].result | { code, stdout, stderr } | The recorded RunResult to replay. | | expected | any (optional) | The output the test asserts the code under test produces. |

{
  "name": "valid",
  "description": "A minimal valid fixture with one recorded ffprobe call.",
  "calls": [
    { "match": { "cmd": "ffprobe", "argsInclude": ["a.mp3"] }, "result": { "code": 0, "stdout": "{}", "stderr": "" } }
  ],
  "expected": { "ok": true }
}

test/fixtures/parse/ holds real ffprobe/ffmpeg output recorded from small MP3s generated with ffmpeg (probe JSON, cover probe, volumedetect + ebur128 levels logs, silencedetect head and tail windows). Each fixture's description ends with the exact command it was recorded with, and its expected is what the parsers in src/core/parse.ts must return. The engine tests reuse the same recordings.

The terminal double is createTerminalDouble() from test/support/terminal-double.ts. It is passed as Ctx.tty ({ stdin, stdout }; absent means process.stdin and process.stdout) so the TUI entry launch(ctx) draws to it instead of the real terminal. press(data) sends a keystroke and frames records every frame drawn. Screen tests usually render <App> with ink-testing-library instead and write keys to its stdin. The TUI pipeline tests (test/tui/pipeline.test.tsx) build Ctx as a plain literal whose run is the effects double from test/support/effects.ts over the recorded check, fix and verify fixtures, with a real temporary book folder. They assert on the pipeline's step states, row counts, the run double's recorded commands and the files written, never on colours or copy.

Use real temporary directories (node:fs with os.tmpdir()) for file-system effects. Assert on exit codes, JSON output, counts, recorded commands and file effects, not on display text.

CI (.github/workflows/ci.yml) runs lint, typecheck, coverage and build on Ubuntu and macOS against Node.js 20 and 22. It fails if the e2e suite was skipped, and checks that doctor exits 3 when mp3gain is missing from PATH.