---
name: macos-audiobook-generator
description: Convert any Markdown book to spoken audio (.m4a) using macOS built-in `say` command. Splits by chapters, cleans markdown, uses Russian voices Milena/Yuri.
category: media
---

# macOS Audiobook Generator

Convert a Markdown book into chapter-by-chapter audio files using the built-in macOS `say` command. No external APIs or paid services needed.

## Prerequisites
- macOS (uses `say` and `afconvert` — both built-in)
- Russian voices: Milena (female), Yuri (male) — pre-installed on macOS

## Script

Save as `compile_audiobook.py`:

```python
#!/usr/bin/env python3
"""Convert book chapters to audio using macOS 'say' command."""
import re, os, subprocess, sys

# === CONFIG ===
MD_PATH = os.path.expanduser("~/Desktop/BOOK.md")
OUT_DIR = os.path.expanduser("~/Desktop/BOOK_Audio")
VOICE = "Milena"  # or "Yuri" — check with: say -v '?' | grep ru
TEMP_DIR = "/tmp/book_audio"

os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(TEMP_DIR, exist_ok=True)

# Read the book
with open(MD_PATH, 'r') as f:
    lines = f.readlines()

# Split into chapters by # headers
chapters = []
current_title = "Title"
current_lines = []

for line in lines:
    if line.startswith('# ') and not line.startswith('## '):
        if current_lines:
            chapters.append((current_title, ''.join(current_lines)))
        current_title = line.strip().lstrip('# ').strip()
        current_lines = [line]
    else:
        current_lines.append(line)

if current_lines:
    chapters.append((current_title, ''.join(current_lines)))

print(f"Found chapters: {len(chapters)}")

def clean_text(text):
    """Remove markdown syntax, keep readable text."""
    text = re.sub(r'<[^>]+>', '', text)
    text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
    text = re.sub(r'`[^`]+`', '', text)
    text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
    text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
    text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
    text = re.sub(r'\*(.+?)\*', r'\1', text)
    text = re.sub(r'^---+$', '', text, flags=re.MULTILINE)
    text = re.sub(r'\|[^|]+\|', '', text)
    text = re.sub(r'^[\s\-\|]+$', '', text, flags=re.MULTILINE)
    text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
    text = re.sub(r'^>\s?', '', text, flags=re.MULTILINE)
    text = re.sub(r'\n{3,}', '\n\n', text)
    return text.strip()

skipped = 0
for i, (title, raw_text) in enumerate(chapters):
    cleaned = clean_text(raw_text)
    if len(cleaned) < 80:
        skipped += 1
        continue

    ch_num = i + 1
    safe_title = re.sub(r'[^\w\s\-]+', '', title)[:60]
    print(f"Chapter {ch_num}/{len(chapters)}: {safe_title} ({len(cleaned)} chars)")

    txt_path = os.path.join(TEMP_DIR, f"ch_{ch_num:03d}.txt")
    with open(txt_path, 'w') as f:
        f.write(cleaned)

    aiff_path = os.path.join(TEMP_DIR, f"ch_{ch_num:03d}.aiff")
    m4a_path = os.path.join(OUT_DIR, f"{ch_num:03d}_{safe_title}.m4a")

    # say → aiff
    result = subprocess.run(
        ['say', '-v', VOICE, '-o', aiff_path, '-f', txt_path],
        capture_output=True, text=True, timeout=300
    )
    if result.returncode != 0:
        print(f"  say error: {result.stderr}")
        continue

    # aiff → m4a (compress)
    result = subprocess.run(
        ['afconvert', '-f', 'm4af', '-d', 'aac', aiff_path, m4a_path],
        capture_output=True, text=True, timeout=60
    )
    if result.returncode != 0:
        subprocess.run(['mv', aiff_path, m4a_path.replace('.m4a', '.aiff')])
        print(f"  Conversion failed, kept .aiff")
    else:
        os.remove(aiff_path)

    print(f"  {os.path.getsize(m4a_path)/1024:.0f} KB")

# Cleanup temp
for f in os.listdir(TEMP_DIR):
    try: os.remove(os.path.join(TEMP_DIR, f))
    except: pass

# Summary
total = sum(os.path.getsize(os.path.join(OUT_DIR, f)) for f in os.listdir(OUT_DIR))
print(f"\nDone: {len(os.listdir(OUT_DIR))} files, {total/1024/1024:.1f} MB in {OUT_DIR}")
print(f"Skipped short sections: {skipped}")
```

## Usage

```bash
python3 /tmp/compile_audiobook.py
```

Output: numbered `.m4a` files in `~/Desktop/BOOK_Audio/`, playable in Finder, Music, or any audio player.

## Voice quality

- **Milena** — Russian female voice, suitable for long-form narration
- **Yuri** — Russian male voice, more formal

Quality is macOS built-in TTS — not studio-grade, but clear and listenable. For professional quality, migrate to ElevenLabs or Speechify.

## Limitations

- `say` can handle ~30K characters per chapter safely
- Total book size: ~600-800K chars → ~70-80 MB of audio
- Processing time: ~5-15 minutes for a full book depending on size
- Only Russian voices tested; other languages need different voice names
