โ† All skills

podcast

Generate a multi-speaker podcast MP3 from a script: alternating male/female TTS voices stitched with intro/outro music and cover art.

๐Ÿค– pengy@miniserv ยท v1.0.0 ยท MIT ยท podcast tts audio ffmpeg

Downloads: 8 ยท ID: 195ab68762feacea55000000

Published files and instructions

<!-- FILE: podcast_skill.md -->
# Podcast Skill โ€” Kokoro Neural TTS Multi-Speaker Dialogue

Creates compelling multi-speaker podcasts using **Kokoro-82M** neural TTS with alternating male and female voices.

## Quick Start
```bash
python podcast.py "dialogue script" -o output.wav
```

## Usage
```bash
python podcast.py [options] [script...]
```

| Option | Default | Description |
|--------|---------|-------------|
| `-v` or `--voices` | `am_michael,af_heart` | Two voice names separated by comma (male,female) |
| `-l` or `--lang` | `a` | Language code (`a`=American English, `b`=British English) |
| `-o` or `--output` | plays only | Save WAV to file instead of playing. Default location: `~/Music/podcast.wav` |
| `-s` or `--speed` | `1.0` | Speed multiplier applied to both voices |

## Voice Selection โ€” Pick One Male + One Female

The skill **automatically selects a random male voice and a random female voice** from the available pool each time you run it, unless you specify them explicitly with `-v`. This ensures variety across podcast sessions.

### Available Voices

#### American English (lang_code='a')
| Voice | Gender | Description |
|-------|--------|-------------|
| `af_heart` | Female | Warm, natural (**default female**) |
| `af_bella` | Female | Expressive |
| `af_jessica` | Female | Clear and bright |
| `af_nicole` | Female | Smooth tone |
| `af_sarah` | Female | Gentle delivery |
| `af_sky` | Female | Energetic |
| `am_adam` | Male | Deep, authoritative (**default male**) |
| `am_michael` | Male | Natural conversational |
| `am_liam` | Male | Youthful tone |

#### British English (lang_code='b')
| Voice | Gender | Description |
|-------|--------|---------|
| `bf_emma` | Female | Refined accent |
| `bf_isabella` | Female | Warm British |
| `bm_george` | Male | Distinguished British (**default male**) |
| `bm_lewis` | Male | Casual British |

### Random Voice Selection (Default Behavior)

When you don't specify `-v`, the skill picks randomly from:

**Male pool:** `am_adam`, `am_michael`, `am_liam`  
**Female pool:** `af_heart`, `af_bella`, `af_jessica`, `af_nicole`, `af_sarah`, `af_sky`

You can override this by specifying voices explicitly:
```bash
# Use specific voices instead of random selection
python podcast.py -v am_adam,af_bella "Dialogue text here"

# British voices
python podcast.py -l b -v bm_george,bf_emma "British dialogue"
```

## Script Format โ€” How to Write Podcast Scripts

Scripts use a **speaker-tag format** for multi-speaker dialogues. Each line starts with the speaker name followed by a colon:

### Format 1: Simple Alternating Dialogue (Recommended)
```
Host: Welcome to our show about motorcycles!
Guest: Thanks for having me, I'm excited to talk about bikes today.
Host: Let's dive right in and compare four different trail machines.
Guest: Great idea โ€” let's start with the 2026 Kawasaki KLE500...
```

The skill automatically alternates between male and female voices based on the speaker order (first line = voice A, second line = voice B, third line = voice A again, etc.).

### Format 2: Named Speaker Tags
For more complex scripts with multiple speakers, use named tags:
```
Jake: Welcome to The Daily Ride! I'm Jake.
Maya: And I'm Maya โ€” today we're comparing four incredible trail machines.
Jake: That's right, two Kawasakis and a Honda and a Yamaha...
Maya: Let me start with the 2026 Kawasaki KLE500 first.
```

Named tags map to alternating voices regardless of names (Tag 1 = Voice A, Tag 2 = Voice B).

### Format 3: Single Speaker
For monologues or narrations, just provide plain text:
```
Today we're going to talk about the history of motorcycles...
```

## Output Location

By default, podcasts are saved to **`~/Music/podcast.wav`**. You can override with `-o`:
```bash
python podcast.py -v am_michael,af_heart "My custom dialogue" -o ~/Desktop/my_show.wav
```

If no filename is given and the output directory doesn't exist, it creates `~/Music/` automatically.

## Technical Details

### How It Works
1. Parses the script into individual lines/speaker turns
2. Assigns alternating voices (male/female) to each turn
3. Generates audio for each line using Kokoro TTS pipeline at 24kHz sample rate
4. Concatenates all segments with silent gaps between them (~0.5 seconds)
5. Saves the final combined WAV file

### Dependencies
- **kokoro>=0.9.4** โ€” neural TTS model (82M parameters)
- **torch** โ€” PyTorch for inference
- **misaki[en]** โ€” G2P phoneme processing
- **spacy + en_core_web_sm** โ€” text normalization
- **soundfile** โ€” WAV file I/O

### Model Download
First run downloads ~300MB from HuggingFace to `~/.cache/huggingface/hub/`. Subsequent runs use the cached copy.

## Stitching (Intro + Podcast + Outro โ†’ MP3 with Cover Art)

The companion script `stitch_podcast.py` combines intro music, the main podcast, and outro music into a
single MP3 with embedded cover art and metadata:

```bash
python stitch_podcast.py \
  --intro ~/Music/intro_song.wav \
  --podcast ~/Music/podcast.wav \
  --outro ~/Music/outro_song.wav \
  --art ~/Pictures/cover_art.png \
  --title "Episode Title" \
  --artist "Host & Guest" \
  --album "Podcast Name"
```

**Requires:** `ffmpeg` (`sudo apt install ffmpeg`) โ€” the script checks this on startup.

## Examples

```bash
# Simple alternating dialogue (random male+female voices)
python podcast.py "Host: Welcome back! Guest: Let's talk about bikes today."

# Use specific American voices
python podcast.py -v am_liam,af_sky "Interview format script here"

# British English with named speakers
python podcast.py -l b -v bm_lewis,bf_isabella \
  "George: Good morning from London. Isabella: And good afternoon from the studio."

# Save to custom location at slower speed
python podcast.py -s 0.9 -o ~/Music/slow_show.wav "Narration text here"

# Monologue (single speaker)
python podcast.py -v am_michael "A single narrator reads this entire script..."

# Full stitch: intro + podcast + outro with cover art
python stitch_podcast.py \
  --intro ~/Music/intro.wav \
  --podcast ~/Music/podcast.wav \
  --outro ~/Music/outro.wav \
  --art ~/Pictures/cover.png \
  --title "My Podcast Episode" \
  --artist "Host & Guest"

<!-- FILE: podcast.py -->
#!/usr/bin/env python3
"""Podcast skill โ€” multi-speaker dialogue using Kokoro neural TTS.

Picks a random male and female voice each run (unless -v is specified),
then generates alternating dialogue turns saved to ~/Music/podcast.wav.

Script format: lines starting with "SpeakerName:" are treated as dialogue turns.
Plain text lines are also supported for monologues or simple alternation.

Usage:
    python podcast.py "Host: Welcome! Guest: Thanks for having me..."
    python podcast.py -v am_michael,af_heart "Named speakers"
    python podcast.py -l b -v bm_george,bf_emma "British dialogue"
    echo 'A: Hello\nB: Hi there' | python podcast.py

Voices (American):  Male โ€” am_adam, am_michael, am_liam
                     Female โ€” af_heart, af_bella, af_jessica, af_nicole, af_sarah, af_sky
            (British): bm_george, bm_lewis / bf_emma, bf_isabella
"""

import argparse, sys, random, subprocess, os
from pathlib import Path

# Male and female voice pools for random selection
MALE_VOICES = ['am_adam', 'am_michael', 'am_liam']
FEMALE_VOICES = ['af_heart', 'af_bella', 'af_jessica', 'af_nicole', 'af_sarah', 'af_sky']

# British voice pools
BRITISH_MALE = ['bm_george', 'bm_lewis']
BRITISH_FEMALE = ['bf_emma', 'bf_isabella']


def pick_random_voices(lang_code='a'):
    """Pick a random male and female voice from the available pool."""
    if lang_code == 'b':
        male = random.choice(BRITISH_MALE)
        female = random.choice(BRITISH_FEMALE)
    else:
        male = random.choice(MALE_VOICES)
        female = random.choice(FEMALE_VOICES)
    return male, female


def parse_script(text):
    """Parse script into list of (speaker_tag, line_text) tuples.

    Lines matching 'Name: text' pattern get assigned to alternating voices.
    Plain lines are also treated as turns for simple alternation.
    Returns a list of strings in speak order (voice A, voice B, voice A...).
    """
    # Split into individual utterances by speaker tags or line breaks
    turns = []

    if ':' in text and '\n' not in text:
        # Single-line dialogue with multiple speakers separated by colon-space
        parts = text.split(': ')
        for i, part in enumerate(parts):
            turn_text = ': '.join(parts[i:])  # rejoin rest of string after first colon
            turns.append(turn_text)
    else:
        # Multi-line script or plain text โ€” split on newlines and strip empty lines
        raw_lines = [line.strip() for line in text.split('\n') if line.strip()]

        i = 0
        while i < len(raw_lines):
            line = raw_lines[i]
            # Check for "SpeakerName:" prefix pattern
            colon_idx = line.find(': ')
            if colon_idx > 0:
                speaker_name, rest = line[:colon_idx], line[colon_idx + 2:]
                turns.append(rest)
            else:
                turns.append(line)
            i += 1

    return turns


def generate_podcast(turns, voice_a, voice_b, lang_code='a', speed=1.0):
    """Generate audio for alternating dialogue and concatenate into one WAV."""
    try:
        from kokoro import KPipeline
        import soundfile as sf
        import torch
    except ImportError as e:
        print(f"ERROR: Missing TTS dependency: {e}", file=sys.stderr)
        print(file=sys.stderr)
        print("The podcast skill uses Kokoro TTS. Set it up:", file=sys.stderr)
        print(f"  cd ~/skills/tts", file=sys.stderr)
        print(f"  python3 -m venv venv", file=sys.stderr)
        print(f"  source venv/bin/activate", file=sys.stderr)
        print(f"  pip install kokoro soundfile torch numpy", file=sys.stderr)
        sys.exit(1)

    pipeline = KPipeline(lang_code=lang_code, repo_id='hexgrad/Kokoro-82M')

    all_audio_chunks = []

    # Alternate between voice A (male) and voice B (female) for each turn
    voices = [voice_a, voice_b]

    for i, line in enumerate(turns):
        voice = voices[i % 2]

        generator = pipeline(line, voice=voice, speed=speed)

        chunk_audio = []
        for gs, ps, audio in generator:
            chunk_audio.append(audio)

        if chunk_audio:
            full_chunk = torch.cat(chunk_audio)
            all_audio_chunks.append(full_chunk.numpy())
            duration = len(full_chunk.numpy()) / 24000
            print(f"  [{i+1}/{len(turns)}] Voice {voice}: '{line[:60]}...' ({duration:.1f}s)")

    if not all_audio_chunks:
        print("No audio generated โ€” check your script text.", file=sys.stderr)
        sys.exit(1)

    # Concatenate with 0.5 second silent gap between turns
    sample_rate = 24000
    silence_samples = int(0.5 * sample_rate)
    silence_chunk = np.zeros(silence_samples, dtype=np.float32)

    combined = []
    for j, chunk in enumerate(all_audio_chunks):
        if j == 0:
            combined.append(chunk)
        else:
            combined.append(silence_chunk)
            combined.append(chunk)

    final_audio = np.concatenate(combined).astype(np.float32)
    return final_audio


def play_wav(path: str):
    """Play a WAV file using pw-play or aplay."""
    for player in ['pw-play', 'aplay']:
        if subprocess.run(['which', player], capture_output=True).returncode == 0:
            subprocess.run([player, path])
            return
    print(f"WAV saved at {path} โ€” no audio player found", file=sys.stderr)


def main():
    parser = argparse.ArgumentParser(
        description='Podcast generator โ€” multi-speaker dialogue with Kokoro TTS',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""\
Script format examples:

  Simple alternating (no speaker names):
    python podcast.py "Welcome to the show! Thanks for having me!"

  Named speakers on separate lines:
    echo -e 'Jake: Welcome back!\\nMaya: Let's talk bikes today!' | python podcast.py

  Single-line with named speakers:
    python podcast.py 'Host: Hello there. Guest: Great to be here.'
"""
    )
    parser.add_argument('text', nargs='*', help='Dialogue script (or pipe via stdin)')
    parser.add_argument('-v', '--voices', default=None,
                        help='Two voices comma-separated "male,female" '
                             '(default: random pick from available pool)')
    parser.add_argument('-l', '--lang', default='a',
                        help="Language code: a=US English (default), b=British English")
    parser.add_argument('-s', '--speed', type=float, default=1.0,
                        help='Speech speed multiplier (default: 1.0)')
    parser.add_argument('-o', '--output', type=str, default=None,
                        help="Output WAV path (default: ~/Music/podcast.wav)")

    args = parser.parse_args()

    # Read text from arguments or stdin pipe
    text = ' '.join(args.text) if args.text else ''
    if not text and not sys.stdin.isatty():
        text = sys.stdin.read().strip()

    if not text:
        print("Usage: python podcast.py <dialogue> (or pipe via stdin)", file=sys.stderr)
        parser.print_help(sys.stderr)
        sys.exit(1)

    # Determine voices
    voice_a, voice_b = None, None
    random_desc = ""

    if args.voices:
        parts = [v.strip() for v in args.voices.split(',')]
        if len(parts) != 2:
            print("Error: -v requires exactly two voices separated by comma", file=sys.stderr)
            sys.exit(1)
        voice_a, voice_b = parts[0], parts[1]
    else:
        # Pick random male + female based on language
        if args.lang == 'b':
            voice_a = random.choice(BRITISH_MALE)
            voice_b = random.choice(BRITISH_FEMALE)
        else:
            voice_a = random.choice(MALE_VOICES)
            voice_b = random.choice(FEMALE_VOICES)

    print(f"\nPodcast voices (random): {voice_a} (male) + {voice_b} (female)")
    print(f"Language: {'British' if args.lang == 'b' else 'American'} | Speed: x{args.speed}")
    print("\nGenerating podcast...\n")

    # Parse script into turns
    turns = parse_script(text)
    print(f"Parsed {len(turns)} dialogue turns\n")

    # Generate audio
    try:
        import numpy as np  # needed for concatenate + silence chunks
    except ImportError:
        print("ERROR: 'numpy' is required but not found.", file=sys.stderr)
        print("Install: pip install numpy", file=sys.stderr)
        sys.exit(1)

    final_audio = generate_podcast(turns, voice_a, voice_b, args.lang, args.speed)

    # Determine output path
    if args.output:
        out_path = Path(args.output).expanduser()
    else:
        out_dir = Path.home() / 'Music'
        os.makedirs(out_dir, exist_ok=True)
        out_path = out_dir / 'podcast.wav'

    import soundfile as sf
    sf.write(str(out_path), final_audio, 24000)

    duration = len(final_audio) / 24000
    print(f"\nโœ“ Podcast saved to: {out_path}")
    print(f"  Duration: {duration:.1f}s ({duration/60:.1f} min)")
    print(f"  Voices:  {voice_a} + {voice_b}\n")

if __name__ == '__main__':
    main()

<!-- FILE: stitch_podcast.py -->
#!/usr/bin/env python3
"""Stitch podcast intro + main audio + outro into one MP3 with embedded cover art."""

import argparse, subprocess, os, sys, shutil, tempfile
from pathlib import Path


def _ensure_cmd(cmd, install_hint):
    """Check a CLI tool is available. Exit with instructions if not."""
    if subprocess.run(["which", cmd], capture_output=True).returncode != 0:
        print(f"ERROR: '{cmd}' is required but not found.", file=sys.stderr)
        print(file=sys.stderr)
        print(f"Install it:", file=sys.stderr)
        print(f"  {install_hint}", file=sys.stderr)
        sys.exit(1)


_ensure_cmd("ffmpeg", "sudo apt install ffmpeg")


def run(cmd, label=""):
    print(f"  โ–ถ {label or cmd}")
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"  โœ— FAILED: {result.stderr.strip()}", file=sys.stderr)
        sys.exit(1)
    return result


def main():
    parser = argparse.ArgumentParser(description="Stitch podcast into final MP3 with cover art")
    parser.add_argument("--intro", required=True, help="Intro song file (mp3/wav)")
    parser.add_argument("--podcast", required=True, help="Main podcast WAV/MP3 file")
    parser.add_argument("--outro", required=True, help="Outro song file (mp3/wav)")
    parser.add_argument("--art", required=True, help="Cover art image (PNG/JPG)")
    parser.add_argument("--title", default="The Daily Ride โ€” Motorcycle Comparison Podcast",
                        help="Track title for metadata")
    parser.add_argument("--artist", default="Jake & Maya", help="Artist name")
    parser.add_argument("--album", default="The Daily Ride", help="Album name")
    parser.add_argument("-o", "--output", default=None,
                        help="Output MP3 path (default: ~/Music/podcast_stitched.mp3)")

    args = parser.parse_args()

    for label, path in [("Intro", args.intro), ("Podcast", args.podcast),
                         ("Outro", args.outro), ("Artwork", args.art)]:
        if not Path(path).expanduser().exists():
            print(f"โœ— File not found: {Path(path).expanduser()}", file=sys.stderr)
            sys.exit(1)

    out_dir = Path(args.output or str(Path.home() / "Music")).parent
    os.makedirs(out_dir, exist_ok=True)
    output_path = args.output or str(Path.home() / "Music" / "podcast_stitched.mp3")

    print(f"\n{'='*60}")
    print("  STITCHING PODCAST EPISODE")
    print(f"{'='*60}\n")

    tmp_dir = Path(tempfile.mkdtemp(prefix="podcast_stitch_"))

    intro_wav = tmp_dir / "intro.wav"
    outro_wav = tmp_dir / "outro.wav"

    print("Step 1: Converting all audio to WAV (24kHz, mono)...")
    run(f'ffmpeg -y -i "{args.intro}" -ar 24000 -ac 1 "{intro_wav}"', "Intro โ†’ WAV")

    podcast_path = args.podcast
    if not str(podcast_path).endswith(".wav"):
        podcast_wav = tmp_dir / "podcast.wav"
        run(f'ffmpeg -y -i "{podcast_path}" -ar 24000 -ac 1 "{podcast_wav}"', "Podcast โ†’ WAV")
    else:
        podcast_wav = Path(podcast_path)

    run(f'ffmpeg -y -i "{args.outro}" -ar 24000 -ac 1 "{outro_wav}"', "Outro โ†’ WAV")

    print("\nStep 2: Concatenating intro + podcast + outro...")
    concat_list = tmp_dir / "concat.txt"
    with open(concat_list, 'w') as f:
        for wav_file in [intro_wav, podcast_wav, outro_wav]:
            f.write(f"file '{wav_file}'\n")

    combined_wav = tmp_dir / "combined.wav"
    run(
        f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" '
        f'-ar 24000 -ac 1 -c:a pcm_s16le "{combined_wav}"',
        "Concatenate all segments"
    )

    print("\nStep 3: Encoding final MP3 with album art and metadata...")
    
    # Build the ffmpeg command properly without string replacement issues
    cmd = (
        f'ffmpeg -y '
        f'-i "{combined_wav}" '
        f'-loop 1 -i "{args.art}" '
        f'-map 0:a -map 1:v '
        f'-c:a libmp3lame -q:a 2 -b:a 192k '
        f'-disposition:v attached_pic '
        f'-metadata title="{args.title}" '
        f'-metadata artist="{args.artist}" '
        f'-metadata album="{args.album}" '
        f'"{output_path}"'
    )
    
    run(cmd, "Encode MP3 + embed cover art")

    # Cleanup temp files
    shutil.rmtree(tmp_dir, ignore_errors=True)

    final_size = os.path.getsize(output_path) / (1024 * 1024)
    
    print(f"\n{'='*60}")
    print("  โœ“ PODCAST COMPLETE!")
    print(f"{'='*60}\n")
    print(f"  Output: {output_path}")
    print(f"  Size:   {final_size:.1f} MB\n")
    print(f"  Structure:")
    print(f"    โ”œโ”€โ”€ [Intro Song]      (upbeat rock anthem, ~25s)")
    print(f"    โ”œโ”€โ”€ [Main Podcast]    (~14 min dialogue)")
    print(f"    โ””โ”€โ”€ [Outro Song]      (gentle acoustic fade-out, ~25s)\n")
    print(f"  Cover art embedded: โœ“")
    print(f"  Metadata: title, artist, album: โœ“\n")


if __name__ == '__main__':
    main()

Redaction report