Created
February 13, 2026 01:43
-
-
Save tux-peng/31e7dd00aff4d4ab2ff7b82a5bb9b26e to your computer and use it in GitHub Desktop.
convert m4b chapters to mp3, vorbis, opus or speex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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