Created
January 5, 2026 03:58
-
-
Save yukiarimo/6bb78d04b6157590e417b6adb9677cf9 to your computer and use it in GitHub Desktop.
Audiobook Maker
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
| 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