Skip to content

Instantly share code, notes, and snippets.

@butuzov
Created August 10, 2018 06:07
Show Gist options
  • Save butuzov/fa7d456ebc3ec0493c0a10b73800bf42 to your computer and use it in GitHub Desktop.
Save butuzov/fa7d456ebc3ec0493c0a10b73800bf42 to your computer and use it in GitHub Desktop.
Convert mp3's to m4b using `ffmpeg`

Let's imagine we have a lot of mp3 files ( forexample one of the pluralsite courses converted to mp3 ).

URL=https://www.pluralsight.com/courses/run-effective-meetings
PASS=pass
USER=user
OUTPUT="%(playlist_index)s. %(title)s-%(id)s.%(ext)s"
youtube-dl --username $USER --password $PASS -o $OUTPUT --extract-audio --audio-format mp3 $URL

Once you have a list of files we can start converting it first by combining all mp3 into one, and then converting it to m4a/m4b format.

  ls | grep "mp3" | awk '{printf "file |%s|\n", $0}' | sed -e "s/|/\'/g" > list.txt \
  && ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp3 \
  && ffmpeg -i output.mp3 output.m4a \
  && mv output.m4a output.m4b
@butuzov
Copy link
Author

butuzov commented Feb 7, 2019

Updated version that used as command

#!/usr/bin/env bash
 
abook() {
    local DIR="${1}"

    if [[ ! -d $DIR || -z $1 ]]; then
        DIR=$(pwd)
    fi

    # generating random name
    local NAME=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 6 | head -n 1)

    # generating book
    ls -1 "${DIR}/"*.mp3 | awk  '{printf "file |%s|\n", $0}' | \
        sed -e "s/|/\'/g" > "${DIR}/${NAME}.txt" \
        && ffmpeg -f concat -safe 0 -i "${DIR}/${NAME}.txt" -c copy "${DIR}/${NAME}.mp3" \
        && ffmpeg -i "${DIR}/${NAME}.mp3" "${DIR}/${NAME}.m4a" \
        && mv "${DIR}/${NAME}.m4a" "${DIR}/$(basename "${DIR}").m4b"

    # Cleanup
    unlink "${DIR}/${NAME}.txt"
    unlink "${DIR}/${NAME}.mp3"
}

abook "$1"

@butuzov
Copy link
Author

butuzov commented Mar 8, 2019

@Samakus1
Copy link

Amazing stuffs here

@johnmaguire
Copy link

For multi-disc directories, you can use the following:

find . | grep "mp3" | awk '{printf "file |%s|\n", $0}' | sed -e "s/|/\'/g" > list.txt \
  && ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp3 \
  && ffmpeg -i output.mp3 output.m4a \
  && mv output.m4a output.m4b

@LinusU
Copy link

LinusU commented Mar 30, 2024

I wanted to convert multiple mp3 files to a single m4b, and ran into a lot of problems. What finally worked for me in the end was something like this:

Step 1, turn each chapter into its own m4b file. In my case the files where named e.g. "Ch 1a", "Ch 1b", ..., "Ch 2a", etc. So for each chapter I created a input file matching the ffmpeg "concat" format, and then ran the following ffmpeg invocation in order to turn every handful of mp3s to a single m4b. The -vn flag is used to strip away the album art (I'll be adding it back in the last step, but it had the wrong dimensions here which made ffmpeg refuse to work with it).

ffmpeg -f concat -safe 0 -i "book-001.txt" -vn "book-001.m4b"

Step 2, create a metadata file that describes every chapter. I did this by running ffprobe on all of the output files from the previous step, and calculated the correct start and end positions for every chapter. This file is saved as book.meta. I also have the album art saved as book.jpg.

ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "book-001.m4b"

Step 3, stitch it all together. I created a final "concat" file (book.txt) which contains one line per created m4b file from step 1. Then used the following ffmpeg invocation to turn it into a single file:

ffmpeg -f concat -safe 0 -i "book.txt" -i "book.meta" -i "book.jpg" -map 0:a -map_metadata 1 -map 2:v -disposition:v:0 attached_pic -c copy -movflags +faststart "book.m4b"

In order to actually do this, I used the following small Node.js script:

const child_process = require('node:child_process')
const fs = require('node:fs')

const files = process.argv.slice(2)
const chapters = []

for (const file of files) {
  if (file.endsWith('Intro.mp3')) {
    chapters.push({ title: 'Intro', files: [file] })
    continue
  }

  if (file.endsWith('a.mp3')) {
    chapters.push({ title: 'Chapter ' + file.match(/Ch (\d+)/)?.[1], files: [file] })
    continue
  }

  if (file.endsWith('The End.mp3')) {
    chapters.push({ title: 'The End', files: [file] })
    continue
  }

  chapters.at(-1).files.push(file)
}

let metadata = ';FFMETADATA1\n'
metadata += 'genre=Thriller\n'
metadata += 'title=Book Name\n'
metadata += 'album=Book Name\n'
metadata += 'artist=Book Author\n'
metadata += 'composer=Book Narrator\n'

let pos = 0
let chapterFiles = []

for (const [idx, chapter] of chapters.entries()) {
  let txt = `book-${String(idx).padStart(3, '0')}.txt`
  let m4b = `book-${String(idx).padStart(3, '0')}.m4b`

  fs.writeFileSync(txt, chapter.files.map(file => `file '${file}'`).join('\n'))
  child_process.execSync(`ffmpeg -f concat -safe 0 -i "${txt}" -vn "${m4b}"`)

  const duration = Number(child_process.execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${m4b}"`, { encoding: 'utf8' }).trim()) * 1_000_000

  metadata += `[CHAPTER]\n`
  metadata += `TIMEBASE=1/1000000\n`
  metadata += `START=${pos}\n`
  metadata += `END=${pos + duration}\n`
  metadata += `title=${chapter.title}\n`

  pos += duration
  chapterFiles.push(m4b)
}

fs.writeFileSync('book.txt', chapterFiles.map(file => `file '${file}'`).join('\n'))
fs.writeFileSync('book.meta', metadata)

child_process.execSync(`ffmpeg -f concat -safe 0 -i "book.txt" -i "book.meta" -i "book.jpg" -map 0:a -map_metadata 1 -map 2:v -disposition:v:0 attached_pic -c copy -movflags +faststart "book.m4b"`)
console.log('book.m4b')

@fikovnik
Copy link

I ended up with a similar approach, using just ffmpeg and bash: https://gist.github.com/fikovnik/963fd95b3ffbde2d139d39dd1d2c73a2

@mtskf
Copy link

mtskf commented Feb 13, 2025

Here you are!
with chapter info.

abook() {
    # Set output filename based on the current directory name
    local output_file="$(basename "$PWD").m4b"
    local temp_list="$(mktemp)"
    local metadata_file="$(mktemp)"
    local temp_mp3="$(mktemp --suffix=.mp3)"
    local temp_m4b="$(mktemp --suffix=.m4b)"

    # Cleanup temporary files when the script exits
    cleanup() {
        rm -f "$temp_list" "$metadata_file" "$temp_mp3" "$temp_m4b"
    }
    trap cleanup EXIT

    # Display help message
    if [[ "$1" == "-h" || "$1" == "--help" ]]; then
        echo "Usage: abook"
        echo "Merges all MP3 files in the current directory into a single M4B file named after the folder."
        return 0
    fi

    # Remove existing output files if they exist
    rm -f "$output_file" "$temp_list" "$metadata_file" "$temp_mp3" "$temp_m4b"

    # Create a list of MP3 files for concatenation (excluding temp.mp3)
    create_mp3_list() {
        rm -f "$temp_list"
        find . -maxdepth 1 -type f -name "*.mp3" ! -name "$(basename "$temp_mp3")" | sort | while read -r file; do
            echo "file '$(realpath "$file")'" >> "$temp_list"
        done
    }

    # Merge MP3 files using ffmpeg
    merge_mp3() {
        ffmpeg -f concat -safe 0 -i "$temp_list" -c copy "$temp_mp3"
        [[ ! -f "$temp_mp3" ]] && { echo "Error: Failed to create temp.mp3"; return 1; }
    }

    # Convert merged MP3 to AAC (M4B format)
    convert_to_m4b() {
        ffmpeg -i "$temp_mp3" -c:a aac -b:a 64k "$temp_m4b"
        [[ ! -f "$temp_m4b" ]] && { echo "Error: Failed to create temp.m4b"; return 1; }
    }

    # Generate chapter metadata
    generate_metadata() {
        echo ";FFMETADATA1" > "$metadata_file"
        echo "title=$(basename "$PWD")" >> "$metadata_file"
        echo "artist=Unknown" >> "$metadata_file"
        echo "album=$(basename "$PWD")" >> "$metadata_file"

        local index=1
        local start_time=0

        find . -maxdepth 1 -type f -name "*.mp3" ! -name "$(basename "$temp_mp3")" | sort | while read -r file; do
            local duration=$(ffmpeg -i "$file" 2>&1 | grep "Duration" | awk '{print $2}' | tr -d ,)
            
            # Convert time to milliseconds
            local seconds=$(echo "$duration" | awk -F: '{ print ($1 * 3600) + ($2 * 60) + $3 }')
            local start_ms=$(echo "$start_time * 1000" | bc)
            local end_ms=$(echo "($start_time + $seconds) * 1000" | bc)

            echo "" >> "$metadata_file"
            echo "[CHAPTER]" >> "$metadata_file"
            echo "TIMEBASE=1/1000" >> "$metadata_file"
            echo "START=$start_ms" >> "$metadata_file"
            echo "END=$end_ms" >> "$metadata_file"
            echo "title=Chapter $index" >> "$metadata_file"

            start_time=$(echo "$start_time + $seconds" | bc)
            index=$((index + 1))
        done
    }

    # Add metadata to the final M4B file
    add_metadata() {
        ffmpeg -i "$temp_m4b" -i "$metadata_file" -map_metadata 1 -c copy "$output_file"
        echo "M4B file created: $output_file"
    }

    # Execute functions
    create_mp3_list
    merge_mp3 || return 1
    convert_to_m4b || return 1
    generate_metadata
    add_metadata
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment