A modern macOS app that combines MP3 files into a single chaptered .m4b
audiobook — with real metadata lookup, embedded cover art, and a background
encode queue.
Download latest → · Releases · Releasing docs
Think Audiobook Builder, but with one-shot drag-folder UX, real metadata lookup from Audnexus / iTunes, and a native SwiftUI interface.
Requires Apple Silicon (arm64). Intel Macs are not supported.
./scripts/bootstrap.sh # installs deps, builds bundled ffmpeg, generates xcodeproj
./scripts/build.sh debug # builds a debug .app at build/Build/Products/Debug/
open build/Build/Products/Debug/AudiobookForge.appFirst run takes ~7-10 min because it compiles ffmpeg + fdk_aac from source (stripped to ~10 MB, only the codecs/muxers we use). Subsequent runs short-circuit.
Or open AudiobookForge.xcodeproj in Xcode after running xcodegen generate.
./scripts/test.sh # whole suite
./scripts/test.sh -only OutputPathResolverTests # single classThe unit suite covers the pure logic — path resolution, codec parsing, chapter-file building, encode-job helpers, formatting, model invariants, queue manager. SwiftUI views and subprocess-driven services (ffmpeg, metadata APIs) aren't unit-tested; those want integration tests.
./scripts/format.sh # auto-fix what SwiftFormat / SwiftLint can
./scripts/lint.sh # check-only; CI runs exactly this and fails on diffConfig lives in .swiftformat and .swiftlint.yml. SwiftFormat handles
whitespace, line wrapping, redundant self, trailing-comma policy, etc.
SwiftLint enforces a curated subset (we disable the rules that fight
idiomatic patterns — short loop vars, deliberate trailing commas, modern
one-liner braces — and opt into the high-signal ones like
first_where, redundant_nil_coalescing, prefer_self_in_static_references).
project.yml # XcodeGen config (the source of truth)
AudiobookForge.xcodeproj/ # generated, gitignored
AudiobookForge/
├── App.swift # @main + Scene
├── ContentView.swift # top-level HSplitView
├── Models/ # Plain Swift structs / @Observable models
├── Services/ # FFmpegRunner, AudioProbe, MetadataSearch, EncodeJob
├── Views/ # SwiftUI views
├── Resources/bin/ # Bundled ffmpeg (gitignored, built from source)
├── Info.plist # Generated by XcodeGen from project.yml
└── AudiobookForge.entitlements
scripts/
├── bootstrap.sh # one-shot dev setup
├── build-ffmpeg.sh # build the bundled ffmpeg + libfdk_aac
└── build.sh # xcodebuild wrapper (debug | release | archive)
.github/workflows/build.yml # CI: unsigned macOS build on every push
Each queued book takes one of two paths through EncodeJob.runInner,
depending on whether the sources can be remuxed losslessly:
Remux path — when every source file is already AAC with a uniform
sample rate + channel layout and the user picked "Match source"
bitrate. A single ffmpeg invocation reads the chapters via the concat
demuxer, picks up an FFMETADATA1 chapter file and an optional cover
image as extra inputs, and writes the final .m4b with -c:a copy (no
re-encoding). Seconds for a 25-hour book.
Re-encode path — everything else. Two phases:
- Phase 1 (parallel) — each chapter source is encoded independently
into an intermediate
.m4avialibfdk_aac, with codec parameters pinned from chapter 0 (sample rate, channels, profile) so the intermediates concatenate losslessly. Up tomin(chapters, activeProcessorCount, 12)ffmpeg children run in parallel viawithThrowingTaskGroup+ aConcurrencyLimiteractor. - Phase 2 (concat + cover) — one final
ffmpegwith the concat demuxer over the intermediates, the FFMETADATA1 chapter file, and the optional cover image, all muxed with-c:a copy. Near-instant.
Both paths write to out.m4b.partial and atomic-rename on success, so
a cancel or crash never leaves a stub .m4b. Output filename comes
from the template {author}/{title}/{title}.m4b by default; collisions
auto-bump via OutputPathResolver to Title (2).m4b, (3), etc.
Up-front each source is probed via AudioProbe.swift:
AVFoundation for duration / tags / codec / sample-rate / channels,
plus a 1-second ffmpeg -t 1 -f null call to read the codec context's
bitrate from stderr (more accurate than AVF's container-divided
estimate for MP3 with embedded cover art).
Progress comes from each ffmpeg's time= lines on stderr, throttled
to per-percent updates and aggregated across parallel chunks by
ProgressAggregator.
MetadataSearch.swift queries two APIs in parallel:
- Audnexus (
https://api.audnex.us) — community Audible aggregator used by Audiobookshelf and Plex. Free, no key, returns ASIN, narrators, series, description, cover URL. - iTunes Search API — free fallback for non-Audible titles.
Hits are deduplicated by (title, author) and shown as clickable rows that
apply to the current project. The selected Audnexus hit is then re-fetched
(enrich) for full description and high-res cover.
See RELEASING.md for the full playbook. TL;DR:
# one-time: add 6 GitHub Secrets (cert, password, team ID, 3x notary creds)
git tag v0.1.0 && git push origin v0.1.0
# → GitHub Actions builds, signs (Developer ID), notarizes, packages a DMG,
# and attaches it to a new GitHub Release.Dry-run locally without burning a tag:
brew install xcodegen create-dmg
scripts/release.sh 0.1.0- Persistable projects (
.audiobookforgedocument type) — queue survives app quit - Reorderable chapter list with merge/split
- Drag chapter boundaries when source files don't map 1:1 to chapters
- Preset library (saved bitrate + filename-template combos)
- Audible region selection on metadata search
- Sparkle auto-updates from GitHub Releases
The AudiobookForge source code is released under the MIT License — use it, fork it, ship a closed-source product based on it, do whatever; just keep the copyright notice and don't sue me if it eats your library.
Release DMGs ship a bundled ffmpeg binary built from source
(scripts/build-ffmpeg.sh) with libfdk_aac statically linked for
AAC encoding. ffmpeg itself is LGPL in the configuration we build;
libfdk_aac is distributed under the Fraunhofer FDK AAC Codec Library
license, which is free for distribution in commercial products. Both
attributions appear in each release's notes alongside the upstream
source links.