Skip to content

Instantly share code, notes, and snippets.

@tux-peng
Created February 13, 2026 01:43
Show Gist options
  • Select an option

  • Save tux-peng/31e7dd00aff4d4ab2ff7b82a5bb9b26e to your computer and use it in GitHub Desktop.

Select an option

Save tux-peng/31e7dd00aff4d4ab2ff7b82a5bb9b26e to your computer and use it in GitHub Desktop.
convert m4b chapters to mp3, vorbis, opus or speex
#!/bin/bash
#convert_m4b.sh book.m4b --(mp3|opus|vorbis|speex)
# 1. Parse Arguments
input_file=""
format="mp3" # Default format
for arg in "$@"; do
case "$arg" in
--opus) format="opus" ;;
--vorbis) format="vorbis" ;;
--speex) format="speex" ;;
*)
if [[ -f "$arg" ]]; then
input_file="$arg"
fi
;;
esac
done
# 2. Validation & Config
if [[ -z "$input_file" ]]; then
echo "Usage: $0 /path/to/book.m4b [--opus | --vorbis | --speex]"
exit 1
fi
if [[ ! -f "$input_file" ]]; then
echo "Error: File '$input_file' not found."
exit 1
fi
# Set encoder parameters based on selected format
if [[ "$format" == "opus" ]]; then
echo "Mode: Opus (High Efficiency)"
ext="opus"
codec="libopus"
# 64k VBR is generally transparent for speech
enc_params="-b:a 64k -vbr on"
elif [[ "$format" == "vorbis" ]]; then
echo "Mode: Vorbis (Ogg)"
ext="ogg"
codec="libvorbis"
# Quality 5 is approx ~160kbps
# Quality 2 is approx ~96kbps
enc_params="-q:a 2"
elif [[ "$format" == "speex" ]]; then
echo "Mode: Speex (Legacy Speech)"
ext="spx"
codec="libspeex"
# Speex requires specific sample rates (8k, 16k, 32k).
# Approx ~36kbps
enc_params="-ar 32000 -q:a 10"
else
echo "Mode: MP3 (Universal Compatibility)"
ext="mp3"
codec="libmp3lame"
# Quality 2 is VBR ~190kbps (Standard High Quality)
enc_params="-q:a 2"
fi
# 3. Path Setup
input_dir=$(dirname "$input_file")
base_name=$(basename "$input_file" .m4b)
# Remove extension if user passed a file that isn't strictly named .m4b but is valid
base_name="${base_name%.*}"
output_dir="${input_dir}/${base_name}"
echo "Input: $input_file"
echo "Output Directory: $output_dir"
mkdir -p "$output_dir"
# 4. Extract Metadata
# Get Artist/Album to tag the output files cleanly
artist=$(ffprobe -v quiet -show_entries format_tags=artist -of default=noprint_wrappers=1:nokey=1 "$input_file")
album=$(ffprobe -v quiet -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 "$input_file")
# Fallbacks if metadata is missing
[[ -z "$artist" ]] && artist="Unknown Artist"
[[ -z "$album" ]] && album="$base_name"
echo "Metadata -> Artist: $artist | Album: $album"
# 5. Get Chapter Data
# We dump chapters to a temp file to parse them safely
chapters_file="${output_dir}/chapters.tmp"
ffprobe -i "$input_file" -print_format flat -show_chapters -loglevel error > "$chapters_file"
total_chapters=$(grep -c "chapters.chapter.*.start_time" "$chapters_file")
echo "Found $total_chapters chapters."
chapter_count=0
# 6. Loop and Convert
while IFS= read -r line; do
# Capture start time
if [[ "$line" =~ chapters\.chapter\.([0-9]+)\.start_time=\"([^\"]+)\" ]]; then
start_time="${BASH_REMATCH[2]}"
fi
# Capture end time
if [[ "$line" =~ chapters\.chapter\.([0-9]+)\.end_time=\"([^\"]+)\" ]]; then
end_time="${BASH_REMATCH[2]}"
fi
# Capture title and execute conversion
if [[ "$line" =~ chapters\.chapter\.([0-9]+)\.tags\.title=\"([^\"]+)\" ]]; then
title="${BASH_REMATCH[2]}"
((chapter_count++))
# Sanitize filename (remove weird chars like / : ?)
safe_title=$(echo "$title" | tr -dc 'a-zA-Z0-9 \-')
# Pad track number (01, 02...)
pad_track=$(printf "%02d" "$chapter_count")
outfile="${output_dir}/${pad_track} - ${safe_title}.${ext}"
echo " [${chapter_count}/${total_chapters}] Converting: ${safe_title}"
# FFmpeg Conversion
# -nostdin: prevents ffmpeg from swallowing loop input
# $enc_params is unquoted to allow multiple flags to expand correctly
ffmpeg -nostdin -v error -stats \
-i "$input_file" \
-ss "$start_time" -to "$end_time" \
-map 0:a \
-c:a "$codec" $enc_params \
-metadata title="$title" \
-metadata artist="$artist" \
-metadata album="$album" \
-metadata track="$chapter_count" \
"$outfile"
fi
done < "$chapters_file"
# Cleanup
rm "$chapters_file"
echo "Success! Files are located in: $output_dir"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment