Skip to content

Instantly share code, notes, and snippets.

@yukiarimo
Created January 5, 2026 03:58
Show Gist options
  • Select an option

  • Save yukiarimo/6bb78d04b6157590e417b6adb9677cf9 to your computer and use it in GitHub Desktop.

Select an option

Save yukiarimo/6bb78d04b6157590e417b6adb9677cf9 to your computer and use it in GitHub Desktop.
Audiobook Maker
import os
import re
import subprocess
import glob
from pathlib import Path
SOURCE_DIR = r"the48lawsofpower"
META_TITLE = "The 48 Laws of Power"
META_ALBUM = "The 48 Laws of Power"
OUTPUT_FILENAME = f"{META_TITLE}.m4b"
META_AUTHOR = "Robert Greene"
META_NARRATOR = "Yuna Ai"
META_GENRE = "Non-Fiction"
META_YEAR = "1998"
META_LANGUAGE = "eng" # ISO 639-2 code (eng, jpn, fra, etc.)
META_PUBLISHER = "Yuna Audio"
META_COPYRIGHT = f"© {META_YEAR} {META_AUTHOR}"
META_SORT_TITLE = f"{META_TITLE} 01" # Sorting Logic: "The 48 Laws of Power 01" ensures Vol 1 sorts before Vol 10.
COVER_ART_FILE = "/Users/yuki/Library/Mobile Documents/com~apple~CloudDocs/Personal/Books/Covers/The 48 Laws of Power.jpg"
def get_duration(file_path):
"""Get the duration of an audio file using ffprobe."""
cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', file_path]
try:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return float(result.stdout.strip())
except Exception as e:
print(f"Error getting duration for {file_path}. Is FFmpeg installed?")
exit(1)
def natural_sort_key(s):
"""Sorts string alphanumerically (e.g., 2.wav before 10.wav)."""
return [int(text) if text.isdigit() else text.lower() for text in re.split('([0-9]+)', s.name)]
def generate_chapter_title(filename):
"""Parses filenames like '23.wav', '25-28.wav', '29-end.wav' into nice chapter titles."""
stem = Path(filename).stem
if '-' in stem:
parts = stem.split('-')
start = parts[0]
end = parts[1]
if end.lower() == 'end': return f"Chapter {start} - End"
else: return f"Chapters {start}-{end}"
return f"Chapter {stem}"
def main():
source_path = Path(SOURCE_DIR)
# Find and Sort WAV files
wav_files = list(source_path.glob("*.wav"))
if not wav_files:
print("No .wav files found in directory!")
return
wav_files.sort(key=natural_sort_key)
print(f"Found {len(wav_files)} files. Processing...")
# Prepare FFMPEG Input Lists and Metadata
concat_list_path = source_path / "concat_list.txt"
ffmetadata_path = source_path / "ffmetadata.txt"
metadata_content = [";FFMETADATA1"]
# Add Global Metadata
metadata_content.append(f"title={META_TITLE}")
metadata_content.append(f"album={META_ALBUM}")
metadata_content.append(f"artist={META_AUTHOR}")
metadata_content.append(f"album_artist={META_AUTHOR}") # Mapped Album Artist to Author
metadata_content.append(f"composer={META_NARRATOR}")
metadata_content.append(f"genre={META_GENRE}")
metadata_content.append(f"date={META_YEAR}")
metadata_content.append(f"language={META_LANGUAGE}")
metadata_content.append(f"copyright={META_COPYRIGHT}") # Added Copyright
metadata_content.append(f"publisher={META_PUBLISHER}") # Added Publisher
metadata_content.append(f"sort_name={META_SORT_TITLE}") # Fixes Sorting (Vol 1 vs 10)
metadata_content.append(f"sort_album={META_SORT_TITLE}") # Good practice to sync sort_name/album
metadata_content.append("media_type=2") # 2 = Audiobook (Critical for Apple Books)
metadata_content.append("gapless_playback=1")
current_time = 0
concat_entries = []
for wav in wav_files:
print(f"Analyzing: {wav.name}")
duration = get_duration(str(wav))
duration_ms = int(duration * 1000)
end_time = current_time + duration_ms
chapter_title = generate_chapter_title(wav.name)
# Add Chapter Entry
metadata_content.append("[CHAPTER]")
metadata_content.append("TIMEBASE=1/1000")
metadata_content.append(f"START={current_time}")
metadata_content.append(f"END={end_time}")
metadata_content.append(f"title={chapter_title}")
safe_path = str(wav.absolute()).replace("'", "'\\''")
concat_entries.append(f"file '{safe_path}'")
current_time = end_time
with open(concat_list_path, 'w', encoding='utf-8') as f: f.write('\n'.join(concat_entries))
with open(ffmetadata_path, 'w', encoding='utf-8') as f: f.write('\n'.join(metadata_content))
print("Starting conversion and merging...")
cmd = ['ffmpeg', '-f', 'concat', '-safe', '0', '-i', str(concat_list_path), '-i', str(ffmetadata_path)] # Construct FFmpeg Command
has_cover = False
if COVER_ART_FILE:
cover_path = Path(COVER_ART_FILE)
if cover_path.exists():
cmd.extend(['-i', str(cover_path)]) # Input 2: Cover Art
has_cover = True
else: print(f"Warning: Cover art {COVER_ART_FILE} not found. Skipping.")
# Mapping streams
cmd.extend(['-map_metadata', '1']) # Map global metadata from file #1
cmd.extend(['-map', '0:a']) # Map audio from file #0
if has_cover:
cmd.extend(['-map', '2']) # Map cover art from file #2
cmd.extend(['-disposition:v', 'attached_pic']) # Mark as cover art
# Encoding Settings
cmd.extend([
'-c:a', 'alac',
'-c:v', 'copy', # Copy the cover art image data directly (don't re-encode jpg)
str(OUTPUT_FILENAME)
])
try:
subprocess.run(cmd, check=True)
print(f"\nSUCCESS! Created: {OUTPUT_FILENAME}")
os.remove(concat_list_path)
os.remove(ffmetadata_path)
except subprocess.CalledProcessError as e: print("\nError: FFmpeg failed to process the file.")
if __name__ == "__main__": main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment