podcast
Generate a multi-speaker podcast MP3 from a script: alternating male/female TTS voices stitched with intro/outro music and cover art.
Downloads: 8 ยท ID: 195ab68762feacea55000000
Generate a multi-speaker podcast MP3 from a script: alternating male/female TTS voices stitched with intro/outro music and cover art.
Downloads: 8 ยท ID: 195ab68762feacea55000000
<!-- 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()