Skip to content

Instantly share code, notes, and snippets.

@qubitrenegade
Created May 5, 2026 08:22
Show Gist options
  • Select an option

  • Save qubitrenegade/d2d3e898bfaf28bd6a1021b62ba2e953 to your computer and use it in GitHub Desktop.

Select an option

Save qubitrenegade/d2d3e898bfaf28bd6a1021b62ba2e953 to your computer and use it in GitHub Desktop.
<#
.SYNOPSIS
Convert one folder of MP3 chapters per audiobook into one M4B file per folder.
.DESCRIPTION
Put this script next to your audiobook folders. Each audiobook should be its own folder.
Each folder should contain numbered MP3 files and, optionally, cover.jpg, cover.jpeg, or cover.png.
Example:
Audiobooks
convert.ps1
02 - Chamber of Secrets
001 - The Worst Birthday.mp3
002 - Dobby's Warning.mp3
003 - The Burrow.mp3
cover.jpg
The script creates this output next to the audiobook folders:
02 - Chamber of Secrets.m4b
This script intentionally refuses unnumbered files by default. That is a safety choice.
A script cannot know the correct chapter order from chapter names alone.
Use -AllowUnnumbered only when you are sure filename sorting matches the correct playback order.
.NOTES
This version does not require PowerShell 7. It avoids PowerShell 7 only syntax and should work
in Windows PowerShell 5.1 as well.
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1
.EXAMPLE
pwsh.exe -ExecutionPolicy Bypass -File .\convert.ps1
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1 -Overwrite -AudioBitrate 192k
.EXAMPLE
powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1 -RootPath "D:\Audiobooks"
#>
param(
# By default, process the folder where this script lives. That makes direct -File execution safer.
[string]$RootPath = $PSScriptRoot,
# Without this switch, the script refuses to replace an existing M4B file.
[switch]$Overwrite,
# Without this switch, MP3 files must start with a number like 001, 002, 003.
[switch]$AllowUnnumbered,
# 128k AAC is a practical default for spoken-word audiobooks.
# Use 192k or higher if the book has music, full-cast production, or you simply want larger files.
[ValidateSet("64k", "96k", "128k", "160k", "192k", "256k", "320k")]
[string]$AudioBitrate = "128k"
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($RootPath)) {
$RootPath = (Get-Location).Path
}
$RootPath = (Resolve-Path -LiteralPath $RootPath).Path
foreach ($tool in @("ffmpeg", "ffprobe")) {
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
throw "$tool was not found in PATH. Install ffmpeg and make sure ffmpeg.exe and ffprobe.exe are available."
}
}
function Invoke-NativeCommandCapture {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string[]]$ArgumentList
)
# PowerShell errors and native executable errors are different things.
# A failed ffmpeg or ffprobe command does not automatically behave like a failed PowerShell command.
# This wrapper captures output and turns a non-zero exit code into a real exception.
$output = & $FilePath @ArgumentList 2>&1
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
$message = ($output | Out-String).Trim()
throw "$FilePath failed with exit code $exitCode.`n$message"
}
return $output
}
function Invoke-NativeCommandShowOutput {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string[]]$ArgumentList
)
# ffmpeg can take a while, so this wrapper lets ffmpeg print progress normally.
# After ffmpeg exits, we still check the exit code so failures are not hidden.
& $FilePath @ArgumentList
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "$FilePath failed with exit code $exitCode."
}
}
function Get-NaturalSortKey {
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
# Normal filename sorting can put 10 before 2.
# Natural sorting pads digit groups so 2 sorts before 10.
# This helps with names like "1 - Intro.mp3", "2 - Chapter.mp3", "10 - Chapter.mp3".
$parts = [regex]::Split($Text.ToLowerInvariant(), '(\d+)')
$keyParts = foreach ($part in $parts) {
if ($part -match '^\d+$') {
$part.PadLeft(20, '0')
} else {
$part
}
}
return ($keyParts -join '')
}
function ConvertTo-FfconcatPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
# ffmpeg's concat demuxer reads a small text file containing one file path per line.
# Forward slashes are friendlier to ffmpeg on Windows.
# Apostrophes need escaping because each path is wrapped in single quotes.
$normalized = $Path.Replace('\', '/')
$normalized = $normalized.Replace("'", "'\''")
return $normalized
}
function Escape-FfmetadataValue {
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
# ffmetadata treats some characters as syntax.
# Escaping them keeps chapter titles from accidentally breaking the metadata file.
$escaped = $Value.Replace('\', '\\')
$escaped = $escaped.Replace("`r", ' ')
$escaped = $escaped.Replace("`n", ' ')
$escaped = $escaped.Replace(';', '\;')
$escaped = $escaped.Replace('#', '\#')
$escaped = $escaped.Replace('=', '\=')
return $escaped.Trim()
}
function Get-ChapterTitle {
param(
[Parameter(Mandatory = $true)]
[System.IO.FileInfo]$File,
[Parameter(Mandatory = $true)]
[int]$Index
)
# The file number controls order, but it usually does not need to appear twice in the chapter title.
# This turns "001 - Owl Post.mp3" into "Owl Post".
$title = $File.BaseName -replace '^\s*\d+[\s\.\-_\)]*', ''
$title = $title.Trim()
if ([string]::IsNullOrWhiteSpace($title)) {
$title = $File.BaseName
}
$chapterNumber = "{0:D3}" -f $Index
return "Chapter $chapterNumber: $title"
}
function Get-AudioDurationMilliseconds {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath
)
# Chapter metadata needs start and end times.
# ffprobe asks the audio file for its duration, then we convert seconds to milliseconds.
$output = Invoke-NativeCommandCapture -FilePath "ffprobe" -ArgumentList @(
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
$FilePath
)
$durationText = (($output | Select-Object -First 1) -as [string]).Trim()
[double]$durationSeconds = 0
$parsed = [double]::TryParse(
$durationText,
[System.Globalization.NumberStyles]::Float,
[System.Globalization.CultureInfo]::InvariantCulture,
[ref]$durationSeconds
)
if (-not $parsed -or $durationSeconds -le 0) {
throw "Could not read a valid duration from: $FilePath"
}
return [int64][math]::Round($durationSeconds * 1000)
}
function Find-CoverImage {
param(
[Parameter(Mandatory = $true)]
[string]$FolderPath
)
# Only use intentional cover filenames.
# This avoids accidentally attaching a random image that happens to be in the folder.
foreach ($name in @("cover.jpg", "cover.jpeg", "cover.png")) {
$candidate = Join-Path $FolderPath $name
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return Get-Item -LiteralPath $candidate
}
}
return $null
}
$folders = @(Get-ChildItem -LiteralPath $RootPath -Directory | Sort-Object Name)
if ($folders.Count -eq 0) {
Write-Warning "No audiobook folders were found in: $RootPath"
exit 0
}
Write-Host "Root folder: $RootPath"
Write-Host "Audio bitrate: $AudioBitrate"
Write-Host "Overwrite existing output: $Overwrite"
foreach ($folder in $folders) {
Write-Host ""
Write-Host "===== Processing: $($folder.Name) ====="
$tempDir = $null
try {
$files = @(
Get-ChildItem -LiteralPath $folder.FullName -File -Filter "*.mp3" |
Sort-Object @{ Expression = { Get-NaturalSortKey -Text $_.Name } }
)
if ($files.Count -eq 0) {
Write-Host "No MP3 files found. Skipping."
continue
}
$unnumbered = @($files | Where-Object { $_.BaseName -notmatch '^\s*\d+' })
if ($unnumbered.Count -gt 0 -and -not $AllowUnnumbered) {
Write-Warning "Skipping '$($folder.Name)' because one or more MP3 files do not start with a number."
Write-Warning "Rename files like 001 - Chapter Name.mp3, or run again with -AllowUnnumbered if you are sure."
foreach ($file in ($unnumbered | Select-Object -First 5)) {
Write-Warning "Unnumbered file: $($file.Name)"
}
continue
}
$outputFile = Join-Path $RootPath ($folder.Name + ".m4b")
if ((Test-Path -LiteralPath $outputFile -PathType Leaf) -and -not $Overwrite) {
Write-Warning "Output already exists and -Overwrite was not used. Skipping: $outputFile"
continue
}
Write-Host "MP3 files found: $($files.Count)"
Write-Host "First file: $($files[0].Name)"
Write-Host "Last file: $($files[$files.Count - 1].Name)"
$cover = Find-CoverImage -FolderPath $folder.FullName
if ($cover) {
Write-Host "Cover image: $($cover.Name)"
} else {
Write-Host "Cover image: none"
}
# Keep temporary files outside the audiobook folder so we never overwrite or delete a user's own files.
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("m4b-convert-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $tempDir | Out-Null
$concatListPath = Join-Path $tempDir "concat.txt"
$metadataPath = Join-Path $tempDir "metadata.txt"
$concatLines = foreach ($file in $files) {
$escapedPath = ConvertTo-FfconcatPath -Path $file.FullName
"file '$escapedPath'"
}
$metadataLines = New-Object System.Collections.Generic.List[string]
[void]$metadataLines.Add(";FFMETADATA1")
[void]$metadataLines.Add("title=$(Escape-FfmetadataValue -Value $folder.Name)")
[int64]$startMs = 0
[int]$chapterIndex = 1
foreach ($file in $files) {
$durationMs = Get-AudioDurationMilliseconds -FilePath $file.FullName
$endMs = $startMs + $durationMs
$chapterTitle = Escape-FfmetadataValue -Value (Get-ChapterTitle -File $file -Index $chapterIndex)
[void]$metadataLines.Add("[CHAPTER]")
[void]$metadataLines.Add("TIMEBASE=1/1000")
[void]$metadataLines.Add("START=$startMs")
[void]$metadataLines.Add("END=$endMs")
[void]$metadataLines.Add("title=$chapterTitle")
$startMs = $endMs
$chapterIndex++
}
# UTF-8 without BOM is a good default for ffmpeg metadata and concat text files.
# This helps filenames and chapter names with accents or non-English characters.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($concatListPath, [string[]]$concatLines, $utf8NoBom)
[System.IO.File]::WriteAllLines($metadataPath, $metadataLines.ToArray(), $utf8NoBom)
$ffmpegArgs = @("-hide_banner")
if ($Overwrite) {
$ffmpegArgs += "-y"
} else {
$ffmpegArgs += "-n"
}
$ffmpegArgs += @(
"-f", "concat",
"-safe", "0",
"-i", $concatListPath,
"-f", "ffmetadata",
"-i", $metadataPath
)
if ($cover) {
$ffmpegArgs += @("-i", $cover.FullName)
$ffmpegArgs += @(
"-map", "0:a:0",
"-map_metadata", "1",
"-map_chapters", "1",
"-map", "2:v:0",
"-c:a", "aac",
"-b:a", $AudioBitrate,
"-c:v", "copy",
"-disposition:v:0", "attached_pic",
"-metadata:s:v", "title=Cover",
"-metadata:s:v", "comment=Cover (front)",
"-metadata", "title=$($folder.Name)",
"-movflags", "+faststart",
$outputFile
)
} else {
$ffmpegArgs += @(
"-map", "0:a:0",
"-map_metadata", "1",
"-map_chapters", "1",
"-c:a", "aac",
"-b:a", $AudioBitrate,
"-metadata", "title=$($folder.Name)",
"-movflags", "+faststart",
$outputFile
)
}
Invoke-NativeCommandShowOutput -FilePath "ffmpeg" -ArgumentList $ffmpegArgs
Write-Host "Created: $outputFile"
}
catch {
Write-Warning "Failed to process '$($folder.Name)': $($_.Exception.Message)"
}
finally {
if ($tempDir -and (Test-Path -LiteralPath $tempDir)) {
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host ""
Write-Host "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment