
Podcastfy Generator
- 1 installs
- 2 repo stars
- Updated June 13, 2026
- kesslerio/podcastfy-generator-openclaw-skill
Generates NotebookLM-style two-host podcast audio from URLs, YouTube videos, PDFs, or text, with multi-lingual output.
About
A skill that generates NotebookLM-style two-host podcast audio from URLs, YouTube videos, PDFs, or text. A developer or creator uses it to turn written or video content into audio discussion format.
- Turns URLs, YouTube videos, PDFs, or text into two-host audio dialogues
- Multi-lingual output with custom podcast and host identities
Podcastfy Generator by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 25, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kesslerio/podcastfy-generator-openclaw-skill --skill podcastfy-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 2 |
| Last updated | June 13, 2026 |
| Repository | kesslerio/podcastfy-generator-openclaw-skill ↗ |
What it does
Generates NotebookLM-style two-host podcast audio from URLs, YouTube videos, PDFs, or text, with multi-lingual output.
Files
Podcastfy Generator 🎙️
Generate AI podcast-style audio conversations from any content. Creates engaging two-host dialogues similar to Google NotebookLM's Audio Overview feature.
Capabilities
- URLs → Fetch article content, generate podcast discussion
- YouTube → Extract transcript, create audio summary
- PDFs → Parse document, synthesize key points as dialogue
- Text/Topics → Generate podcast from plain text or topic prompts
- Multi-lingual → English, German, French, Spanish (auto-detect or specify)
- Custom Identity → Name the podcast, name the hosts, pick their voices
Quick Examples
"Create a podcast about this article: https://example.com/tech-news"
"Turn this YouTube video into a podcast: https://youtube.com/watch?v=..."
"Generate a German podcast discussing quantum computing"
"Make a podcast called 'Deep Dive' with hosts Alex and Sam about this PDF"Usage
Basic Generation
# From URL
<skill>/scripts/generate.py --url "https://example.com/article"
# From YouTube
<skill>/scripts/generate.py --url "https://youtube.com/watch?v=abc123"
# From text
<skill>/scripts/generate.py --text "Your content here..."
# From PDF
<skill>/scripts/generate.py --pdf "/path/to/document.pdf"
# Multiple sources
<skill>/scripts/generate.py --url "https://url1.com" --url "https://url2.com"Podcast Identity
# Name the podcast
<skill>/scripts/generate.py --url "https://..." --podcast-name "Deep Dive"
# Name the hosts (they'll use each other's names in conversation)
<skill>/scripts/generate.py --url "https://..." --host-name Alex --cohost-name Sam
# No podcast name (hosts introduce topic naturally, no show branding)
<skill>/scripts/generate.py --url "https://..." --podcast-name ""
# Full customization
<skill>/scripts/generate.py --url "https://..." \
--podcast-name "Tech Talk" --podcast-tagline "Breaking down the future" \
--host-name Alex --cohost-name KikiLanguage Options
# Auto-detect (default)
<skill>/scripts/generate.py --url "https://example.de/artikel"
# Explicit language
<skill>/scripts/generate.py --url "https://example.com" --lang deSupported: en (English), de (German), fr (French), es (Spanish)
TTS Provider & Voice Options
Default: OpenAI TTS (tts-1-hd with onyx + nova voices)
Optional: ElevenLabs for higher quality, more natural voices:
# Use ElevenLabs with defaults (Daniel + Alice)
<skill>/scripts/generate.py --url "https://..." --elevenlabs
# Custom voices per host
<skill>/scripts/generate.py --url "https://..." --elevenlabs \
--host-voice Daniel --cohost-voice Alice
# OpenAI custom voices
<skill>/scripts/generate.py --url "https://..." \
--host-voice echo --cohost-voice shimmer# Use local sherpa-onnx TTS (free, offline, unlimited)
<skill>/scripts/generate.py --url "https://..." --sherpaOpenAI voices: alloy, echo, fable, onyx, nova, shimmer
ElevenLabs voices (premade): Roger, Sarah, Laura, Charlie, George, Callum, River, Liam, Alice, Matilda, Will, Jessica, Eric, Bella, Chris, Brian, Daniel, Lily, Adam, Bill
Sherpa-onnx (local): Uses Piper VITS models. Voice paths configured in config/conversation.yaml under text_to_speech.sherpa. Requires sherpa-onnx-offline-tts binary (set SHERPA_ONNX_TTS_BIN or install to ~/.openclaw/tools/sherpa-onnx-tts/). Performance note: CPU-based synthesis, typically ~2-10x realtime, requires ~2GB+ RAM, and quality is good but generally below ElevenLabs.
Browse ElevenLabs voices: https://elevenlabs.io/voice-library
All CLI Options
| Option | Description | Example |
|---|---|---|
--url | URL to process (repeatable) | --url https://... |
--text | Plain text content | --text "AI is..." |
--pdf | Path to PDF file | --pdf report.pdf |
--lang | Output language | --lang de |
--podcast-name | Podcast name (empty = none) | --podcast-name "Deep Dive" |
--podcast-tagline | Podcast tagline | --podcast-tagline "..." |
--host-name | Host name (Person1) | --host-name Alex |
--cohost-name | Co-host name (Person2) | --cohost-name Kiki |
--elevenlabs | Use ElevenLabs TTS | --elevenlabs |
--sherpa | Use local sherpa-onnx TTS (free) | --sherpa |
--host-voice | Voice for host | --host-voice Daniel |
--cohost-voice | Voice for co-host | --cohost-voice Alice |
--output, -o | Output file path | -o podcast.ogg |
Output
The script outputs an OGG audio file path. Use the OpenClaw message tool to send it:
# Agent workflow
audio_path = exec("<skill>/scripts/generate.py --url 'https://...'")
message(action="send", media=audio_path, target=user_chat)Configuration
Default podcast style is configured in <skill>/config/conversation.yaml. CLI flags override config values.
Key config options:
podcast_name— Show name (empty = content-driven intro)roles_person1/roles_person2— Host role descriptionstext_to_speech.{provider}.default_voices— Default voice per providerlanguage_voices.{provider}.{Language}— Per-language voice overrides (applied when no--host-voice/--cohost-voiceis set)conversation_style— Style keywords (engaging, concise, etc.)creativity— 0-1 scale (higher = more creative dialogue)
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
OPENAI_API_KEY | Yes | TTS audio generation (default) |
GEMINI_API_KEY | Yes | Transcript/dialogue generation |
ELEVENLABS_API_KEY | No | ElevenLabs TTS (required for --elevenlabs) |
SHERPA_ONNX_TTS_BIN | No | Path to sherpa-onnx-offline-tts binary (for --sherpa) |
Get your ElevenLabs API key at: https://elevenlabs.io/app/settings/api-keys
Installation
First-time setup (run once):
<skill>/scripts/install.shRequirements
- ffmpeg — Audio format conversion
- uv — Python environment management
- Python 3.11+ — Runtime
Troubleshooting
"ffmpeg not found"
Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)
"API key not set"
Ensure OPENAI_API_KEY and GEMINI_API_KEY are in your environment or secrets.conf
Hosts say "Quick Brief" or reference a show name
Set podcast_name: "" in config/conversation.yaml or use --podcast-name ""
Generation takes too long
Podcastfy processes content through LLM + TTS. Expect 30-90 seconds for short podcasts.
Audio quality issues
Try ElevenLabs (--elevenlabs) for more natural voices. OpenAI tts-1-hd is decent but synthetic.
# Generated audio and transcripts
data/audio/
data/transcripts/
# Python
__pycache__/
*.pyc
.venv/
# OS
.DS_Store
# Podcastfy Conversation Configuration
#
# Customize your podcast identity, hosts, voices, and style.
# All fields are optional — podcastfy uses sensible defaults for anything omitted.
# ── Podcast Identity ──────────────────────────────────────────────
# Set podcast_name to "" or remove it to let hosts introduce the topic naturally
# without referencing a show name.
podcast_name: ""
podcast_tagline: "Your AI-powered audio summary"
# ── Host Configuration ────────────────────────────────────────────
# roles_person1 = Host (Person1 / "question" voice)
# roles_person2 = Co-host (Person2 / "answer" voice)
#
# Include the host name in the role to make the LLM use it in dialogue.
# Example: "host named Alex" → hosts will call each other by name.
roles_person1: "host"
roles_person2: "co-host"
# ── Content Settings ──────────────────────────────────────────────
word_count: 500
output_language: "English" # Override with --lang (en, de, fr, es)
creativity: 0.7
# ── Conversation Style ────────────────────────────────────────────
conversation_style:
- engaging
- concise
- informative
dialogue_structure:
- "Hook"
- "Key Points"
- "Takeaway"
engagement_techniques:
- "rhetorical questions"
- "brief anecdotes"
- "clear analogies"
# ── TTS Configuration ────────────────────────────────────────────
# Supported providers: openai, elevenlabs, edge, gemini
# Use --elevenlabs flag or set tts_model to switch providers.
tts_model: "openai"
text_to_speech:
openai:
model: "tts-1-hd"
default_voices:
question: "echo" # Host: warm, smooth, conversational
answer: "shimmer" # Co-host: warm, soft, educational
elevenlabs:
model: "eleven_multilingual_v2"
default_voices:
question: "Daniel" # Host: steady broadcaster (British)
answer: "Alice" # Co-host: clear educator (British)
# Local TTS via sherpa-onnx (free, offline, unlimited)
# Voice format: "/path/to/model_dir" or "/path/to/model_dir:sid=N"
sherpa:
model: "sherpa"
default_voices:
question: "~/.openclaw/tools/sherpa-onnx-tts/models/vits-piper-en_GB-alan-medium"
answer: "~/.openclaw/tools/sherpa-onnx-tts/models/vits-piper-en_US-libritts_r-medium"
# ── Language-Specific Voice Overrides ─────────────────────────────
# Override default voices per language. Only applies when no explicit
# --host-voice / --cohost-voice is passed on the CLI.
# Keys are language names as used by podcastfy (English, German, etc.)
language_voices:
openai:
German:
question: "alloy" # Host: neutral, balanced (better German diction)
answer: "shimmer" # Co-host: warm, soft
# elevenlabs:
# German:
# question: "Daniel"
# answer: "Alice"
sherpa:
German:
question: "~/.openclaw/tools/sherpa-onnx-tts/models/vits-piper-de_DE-thorsten_emotional-medium"
answer: "~/.openclaw/tools/sherpa-onnx-tts/models/vits-piper-de_DE-ramona-low"
# ── Audio Output ──────────────────────────────────────────────────
audio_format: "mp3" # Converted to OGG after generation
ending_message: "Thanks for listening!"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "podcastfy-generator-openclaw-skill"
version = "0.1.0"
description = "Generate AI podcast-style audio conversations from URLs, YouTube videos, PDFs, or text topics"
dependencies = [
"podcastfy",
'openai >=1.0.0,<1.56',
]
[tool.hatch.build.targets.wheel]
packages = ["."]
Podcastfy Generator - OpenClaw Skill 🎙️
Generate AI podcast-style audio conversations from URLs, YouTube videos, PDFs, or text. Creates engaging two-host dialogues similar to Google NotebookLM's Audio Overview feature.

Features
- Multi-source input: URLs, YouTube, PDFs, plain text
- Two-host dialogue: Natural conversation between AI hosts
- Multi-lingual: English, German, French, Spanish
- Short-form: 2-5 minute podcasts (configurable)
- Auto-delivery: Sends OGG audio to Telegram/WhatsApp
Quick Start
# Install (one-time)
./scripts/install.sh
# Generate from URL
./scripts/generate.py --url "https://example.com/article"
# Generate from text
./scripts/generate.py --text "Your content here"
# German output
./scripts/generate.py --url "https://example.com" --lang deRequirements
- Python 3.11+
- uv - Python package manager
- ffmpeg - Audio conversion
- OpenAI API key (
OPENAI_API_KEY) - Gemini API key (
GEMINI_API_KEY)
Installation
1. Clone or install via ClawHub:
clawhub install podcastfy-generator2. Run setup:
./scripts/install.sh3. Set environment variables:
export OPENAI_API_KEY="your-key"
export GEMINI_API_KEY="your-key"Usage Examples
With OpenClaw Agent
User: "Create a podcast about this article: https://..."
Agent: Generates podcast → sends audio to chatCLI
# Single URL
./scripts/generate.py --url "https://example.com/article"
# Multiple URLs
./scripts/generate.py --url "https://url1.com" --url "https://url2.com"
# YouTube video
./scripts/generate.py --url "https://youtube.com/watch?v=abc123"
# Plain text
./scripts/generate.py --text "The history of artificial intelligence..."
# Specify output path
./scripts/generate.py --url "https://..." --output /tmp/podcast.ogg
# Different language
./scripts/generate.py --url "https://..." --lang deConfiguration
Edit config/conversation.yaml to customize:
word_count: Target length (default: 500 words ≈ 3 min)conversation_style: Tone and approachroles_person1/2: Host personalitiesdialogue_structure: Podcast segmentscreativity: LLM temperature (0-1)
Voice Selection
Default voices (OpenAI TTS):
- Host 1:
onyx- Deep, authoritative male - Host 2:
nova- Bright, energetic female
How It Works
1. Content extraction: Fetches and parses input sources 2. Transcript generation: Gemini LLM creates dialogue 3. Audio synthesis: OpenAI TTS with two voices 4. Format conversion: MP3 → OGG for messaging apps 5. Delivery: Returns file path for OpenClaw message tool
Credits
Built on Podcastfy - the open-source NotebookLM alternative.
License
Apache 2.0
#!/usr/bin/env python3
"""Generate AI podcast from URLs, text, PDFs, or YouTube videos.
Usage:
generate.py --url https://example.com/article
generate.py --url https://youtube.com/watch?v=abc123
generate.py --text "Your content here"
generate.py --pdf /path/to/document.pdf
generate.py --url https://url1.com --url https://url2.com --lang de
# Custom podcast identity
generate.py --url https://... --podcast-name "Deep Dive" --host-name Alex --cohost-name Sam
# Custom voices (ElevenLabs)
generate.py --url https://... --elevenlabs --host-voice Daniel --cohost-voice Alice
Output: Prints path to generated OGG audio file.
"""
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
# Ensure we use the skill's venv
SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
VENV_DIR = SKILL_DIR / ".venv"
VENV_PYTHON = VENV_DIR / "bin" / "python"
# Language code mapping
LANGUAGE_MAP = {
"en": "English",
"de": "German",
"fr": "French",
"es": "Spanish",
"english": "English",
"german": "German",
"deutsch": "German",
"french": "French",
"français": "French",
"spanish": "Spanish",
"español": "Spanish",
}
def check_environment(use_elevenlabs: bool = False, use_sherpa: bool = False):
"""Verify environment is properly set up."""
if not VENV_DIR.exists():
print(f"❌ Virtual environment not found at {VENV_DIR}", file=sys.stderr)
print(f" Run: {SKILL_DIR}/scripts/install.sh", file=sys.stderr)
sys.exit(1)
if use_elevenlabs and use_sherpa:
print("❌ --elevenlabs and --sherpa are mutually exclusive", file=sys.stderr)
sys.exit(1)
# Sherpa is fully local — only needs GEMINI_API_KEY for transcript generation
if not use_sherpa and not os.environ.get("OPENAI_API_KEY"):
print("❌ OPENAI_API_KEY not set", file=sys.stderr)
sys.exit(1)
if not os.environ.get("GEMINI_API_KEY"):
print("❌ GEMINI_API_KEY not set", file=sys.stderr)
sys.exit(1)
# langchain_google_genai reads GOOGLE_API_KEY, while this skill uses GEMINI_API_KEY.
os.environ.setdefault("GOOGLE_API_KEY", os.environ.get("GEMINI_API_KEY", ""))
if use_elevenlabs and not os.environ.get("ELEVENLABS_API_KEY"):
print("❌ ELEVENLABS_API_KEY not set (required for --elevenlabs)", file=sys.stderr)
sys.exit(1)
def convert_to_ogg(mp3_path: Path, ogg_path: Path) -> bool:
"""Convert MP3 to OGG using ffmpeg."""
try:
subprocess.run(
["ffmpeg", "-y", "-i", str(mp3_path), "-c:a", "libopus", "-b:a", "128k", str(ogg_path)],
check=True,
capture_output=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"❌ ffmpeg conversion failed: {e.stderr.decode()}", file=sys.stderr)
return False
except FileNotFoundError:
print("❌ ffmpeg not found", file=sys.stderr)
return False
def cleanup_old_files(directory: Path, pattern: str, max_age_hours: int = 1) -> int:
"""Remove files matching pattern older than max_age_hours."""
if not directory.exists():
return 0
removed = 0
max_age_seconds = max_age_hours * 3600
for file_path in directory.glob(pattern):
try:
age = time.time() - os.path.getctime(file_path)
if age > max_age_seconds:
file_path.unlink()
removed += 1
except Exception:
pass
return removed
def build_role(base_role: str, name: str | None) -> str:
"""Build a role string, optionally including the host name.
Examples:
build_role("host", None) → "host"
build_role("host", "Alex") → "host named Alex"
"""
if name:
return f'{base_role} named {name}'
return base_role
# Inner Python code executed inside the podcastfy venv.
# Receives config overrides as a JSON blob via --overrides.
VENV_CODE = '''
import json
import os
import sys
import yaml
from pathlib import Path
from podcastfy.client import generate_podcast
def deep_merge(base, override):
"""Recursively merge override dict into base dict."""
for k, v in override.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
deep_merge(base[k], v)
else:
base[k] = v
return base
# Load base config
config_path = Path(sys.argv[1])
with open(config_path) as f:
config = yaml.safe_load(f)
# Parse arguments
urls = []
text = None
pdf_path = None
overrides = {}
i = 2
while i < len(sys.argv):
if sys.argv[i] == "--url" and i + 1 < len(sys.argv):
urls.append(sys.argv[i + 1])
i += 2
elif sys.argv[i] == "--text" and i + 1 < len(sys.argv):
text = sys.argv[i + 1]
i += 2
elif sys.argv[i] == "--pdf" and i + 1 < len(sys.argv):
pdf_path = sys.argv[i + 1]
i += 2
elif sys.argv[i] == "--overrides" and i + 1 < len(sys.argv):
try:
overrides = json.loads(sys.argv[i + 1])
except json.JSONDecodeError as e:
print(f"Error: invalid --overrides JSON: {e}", file=sys.stderr)
sys.exit(1)
i += 2
else:
i += 1
# Apply overrides via recursive deep merge
deep_merge(config, overrides)
# Handle empty podcast_name: podcastfy hardcodes "Welcome to {name} - {tagline}"
# in its prompt, so empty string produces "Welcome to - ...". Replace with a
# generic name that reads naturally if the user explicitly cleared it.
podcast_name = (config.get("podcast_name") or "").strip()
if not podcast_name:
config["podcast_name"] = "the show"
config["podcast_tagline"] = "Let's get into it"
tts_model = config.get("tts_model", "openai")
# Register sherpa-onnx provider if requested (local, free TTS)
if tts_model == "sherpa":
skill_dir = Path(sys.argv[1]).parent.parent # config/ -> skill root
sys.path.insert(0, str(skill_dir / "scripts"))
from tts_providers.sherpa_onnx import SherpaTTS
from podcastfy.tts.factory import TTSProviderFactory
TTSProviderFactory.register_provider("sherpa", SherpaTTS)
# podcastfy client.py does getattr(config, f"{tts_model.upper()}_API_KEY")
# which fails for sherpa since Config doesn't know about it. Patch it.
from podcastfy.utils.config import Config
Config.SHERPA_API_KEY = None
# sherpa-onnx provider emits WAV bytes.
config["audio_format"] = "wav"
config.setdefault("text_to_speech", {})["audio_format"] = "wav"
# Generate podcast
try:
if urls:
audio_file = generate_podcast(urls=urls, conversation_config=config, tts_model=tts_model)
elif text:
audio_file = generate_podcast(text=text, conversation_config=config, tts_model=tts_model)
elif pdf_path:
audio_file = generate_podcast(urls=[pdf_path], conversation_config=config, tts_model=tts_model)
else:
print("No input provided", file=sys.stderr)
sys.exit(1)
print(audio_file)
except Exception as e:
print(f"Generation failed: {e}", file=sys.stderr)
sys.exit(1)
'''
def generate_podcast(
urls: list[str] | None = None,
text: str | None = None,
pdf_path: str | None = None,
lang: str | None = None,
output_path: str | None = None,
tts_model: str = "openai",
host_voice: str | None = None,
cohost_voice: str | None = None,
podcast_name: str | None = None,
podcast_tagline: str | None = None,
host_name: str | None = None,
cohost_name: str | None = None,
) -> str | None:
"""Generate podcast using podcastfy.
Returns path to generated OGG file, or None on failure.
"""
config_path = SKILL_DIR / "config" / "conversation.yaml"
# Build config overrides as a JSON blob
overrides: dict = {}
if lang:
overrides["output_language"] = LANGUAGE_MAP.get(lang.lower(), lang)
overrides["tts_model"] = tts_model
if podcast_name is not None:
overrides["podcast_name"] = podcast_name
if podcast_tagline is not None:
overrides["podcast_tagline"] = podcast_tagline
# Build host roles with optional names
if host_name:
overrides["roles_person1"] = build_role("host", host_name)
if cohost_name:
overrides["roles_person2"] = build_role("co-host", cohost_name)
# Voice overrides for the active TTS provider
provider = tts_model if tts_model in ("elevenlabs", "sherpa") else "openai"
if host_voice or cohost_voice:
# Explicit CLI voices take highest priority
voices: dict = {}
if host_voice:
voices["question"] = host_voice
if cohost_voice:
voices["answer"] = cohost_voice
overrides["text_to_speech"] = {provider: {"default_voices": voices}}
elif lang:
# Apply language-specific voice defaults from config (if no explicit voices)
# yaml lives in the venv; add its site-packages so we can import it
_venv_site = VENV_DIR / "lib"
for _sp in _venv_site.glob("python*/site-packages"):
if str(_sp) not in sys.path:
sys.path.insert(0, str(_sp))
break
import yaml
with open(config_path) as f:
base_config = yaml.safe_load(f)
lang_voices = base_config.get("language_voices", {})
normalized_lang = LANGUAGE_MAP.get(lang.lower(), lang)
provider_lang_voices = lang_voices.get(provider, {}).get(normalized_lang)
if provider_lang_voices:
overrides["text_to_speech"] = {provider: {"default_voices": provider_lang_voices}}
# Build command
cmd = [str(VENV_PYTHON), "-c", VENV_CODE, str(config_path)]
if urls:
for url in urls:
cmd.extend(["--url", url])
if text:
cmd.extend(["--text", text])
if pdf_path:
cmd.extend(["--pdf", pdf_path])
if overrides:
cmd.extend(["--overrides", json.dumps(overrides)])
# Run podcastfy
print("🎙️ Generating podcast...", file=sys.stderr)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
except subprocess.TimeoutExpired:
print("❌ Generation timed out (5 min limit)", file=sys.stderr)
return None
if result.returncode != 0:
print(f"❌ Generation failed: {result.stderr}", file=sys.stderr)
return None
# Extract the MP3 path from stdout (last line, ignore warnings)
stdout_lines = result.stdout.strip().split('\n')
mp3_line = stdout_lines[-1].strip()
# Handle relative paths from podcastfy
if mp3_line.startswith('./'):
mp3_line = mp3_line[2:]
mp3_path = Path(mp3_line)
if not mp3_path.exists():
print(f"❌ Output file not found: {mp3_path}", file=sys.stderr)
return None
# Convert to OGG
if output_path:
ogg_path = Path(output_path)
else:
ogg_path = mp3_path.with_suffix(".ogg")
print("🔄 Converting to OGG...", file=sys.stderr)
if not convert_to_ogg(mp3_path, ogg_path):
print("⚠️ OGG conversion failed, using MP3", file=sys.stderr)
return str(mp3_path)
# Clean up MP3
try:
mp3_path.unlink()
except Exception:
pass
# Clean up old transcript files (keep for 1 hour for debugging)
transcripts_dir = SKILL_DIR / "data" / "transcripts"
removed = cleanup_old_files(transcripts_dir, "transcript_*.txt", max_age_hours=1)
if removed > 0:
print(f"🧹 Cleaned up {removed} old transcript file(s)", file=sys.stderr)
print("✅ Podcast generated!", file=sys.stderr)
return str(ogg_path)
def main():
parser = argparse.ArgumentParser(
description="Generate AI podcast from content sources"
)
# Content sources
source = parser.add_argument_group("content sources (at least one required)")
source.add_argument(
"--url", action="append", dest="urls",
help="URL to process (can be repeated)",
)
source.add_argument("--text", help="Plain text content to convert")
source.add_argument("--pdf", help="Path to PDF file")
# Podcast identity
identity = parser.add_argument_group("podcast identity")
identity.add_argument(
"--podcast-name",
help='Podcast name (empty string = no name, hosts introduce topic naturally)',
)
identity.add_argument("--podcast-tagline", help="Podcast tagline")
identity.add_argument("--host-name", help="Name for the host (Person1)")
identity.add_argument("--cohost-name", help="Name for the co-host (Person2)")
# TTS / voices
voice_group = parser.add_argument_group("voice configuration")
voice_group.add_argument(
"--elevenlabs", action="store_true",
help="Use ElevenLabs TTS instead of OpenAI (requires ELEVENLABS_API_KEY)",
)
voice_group.add_argument(
"--sherpa", action="store_true",
help="Use local sherpa-onnx TTS (free, offline, no API key needed)",
)
voice_group.add_argument(
"--host-voice",
help="Voice for the host (e.g., 'Daniel', 'onyx')",
)
voice_group.add_argument(
"--cohost-voice",
help="Voice for the co-host (e.g., 'Alice', 'nova')",
)
# Legacy: --voice sets both host and co-host to the same voice
voice_group.add_argument(
"--voice",
help=argparse.SUPPRESS, # Hidden, kept for backwards compat
)
# Output
parser.add_argument("--lang", help="Output language (en, de, fr, es)")
parser.add_argument("--output", "-o", help="Output file path (default: auto)")
args = parser.parse_args()
# Validate inputs
if not any([args.urls, args.text, args.pdf]):
parser.error("At least one of --url, --text, or --pdf is required")
check_environment(use_elevenlabs=args.elevenlabs, use_sherpa=args.sherpa)
# Determine TTS model
if args.sherpa:
tts_model = "sherpa"
elif args.elevenlabs:
tts_model = "elevenlabs"
else:
tts_model = "openai"
# Handle legacy --voice (sets both, but new flags take precedence)
host_voice = args.host_voice
cohost_voice = args.cohost_voice
if args.voice:
if host_voice or cohost_voice:
print(
"⚠️ --voice ignored because --host-voice or --cohost-voice is set",
file=sys.stderr,
)
else:
host_voice = args.voice
cohost_voice = args.voice
# Generate podcast
output = generate_podcast(
urls=args.urls,
text=args.text,
pdf_path=args.pdf,
lang=args.lang,
output_path=args.output,
tts_model=tts_model,
host_voice=host_voice,
cohost_voice=cohost_voice,
podcast_name=args.podcast_name,
podcast_tagline=args.podcast_tagline,
host_name=args.host_name,
cohost_name=args.cohost_name,
)
if output:
print(output)
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env bash
# Install podcastfy in isolated uv environment
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
VENV_DIR="$SKILL_DIR/.venv"
echo "🎙️ Installing Podcastfy Generator..."
# Check prerequisites
if ! command -v uv &> /dev/null; then
echo "❌ uv not found. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh"
exit 1
fi
if ! command -v ffmpeg &> /dev/null; then
echo "❌ ffmpeg not found. Install with: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)"
exit 1
fi
# Create venv if needed
if [ ! -d "$VENV_DIR" ]; then
echo "📦 Creating Python environment..."
uv venv "$VENV_DIR" --python 3.11
fi
# Install dependencies from pyproject.toml (includes openai version constraint)
echo "📥 Installing podcastfy and dependencies..."
uv pip install --python "$VENV_DIR/bin/python" -e "$SKILL_DIR"
# Verify installation
"$VENV_DIR/bin/python" -c "import podcastfy; print(f'✅ Podcastfy {podcastfy.__version__} installed')" 2>/dev/null || \
"$VENV_DIR/bin/python" -c "import podcastfy; print('✅ Podcastfy installed')"
echo ""
echo "✅ Installation complete!"
echo ""
echo "Required environment variables:"
echo " - OPENAI_API_KEY (for TTS)"
echo " - GEMINI_API_KEY (for transcript generation)"
echo ""
echo "Test with:"
echo " $SKILL_DIR/scripts/generate.py --text 'Hello world' --output /tmp/test.ogg"
"""Sherpa-ONNX local TTS provider for podcastfy.
Runs entirely offline using sherpa-onnx-offline-tts binary + Piper VITS models.
Zero API cost, unlimited usage, runs on CPU.
Requires:
- SHERPA_ONNX_TTS_BIN: Path to sherpa-onnx-offline-tts binary
- Model directories with .onnx, tokens.txt, and espeak-ng-data/
Voice format: "<model_dir>" or "<model_dir>:sid=<N>" for multi-speaker models.
Example voices:
- "/path/to/vits-piper-en_US-lessac-high"
- "/path/to/vits-piper-en_US-libritts_r-medium:sid=50"
"""
import os
import re
import shlex
import subprocess
import tempfile
from pathlib import Path
from typing import List, Optional
from podcastfy.tts.base import TTSProvider
WAV_HEADER_SIZE = 44
MIN_AUDIO_BYTES = 100
DEFAULT_TTS_BIN = Path(
"~/.openclaw/tools/sherpa-onnx-tts/runtime/bin/sherpa-onnx-offline-tts"
).expanduser()
def _find_model_files(model_dir: str) -> dict:
"""Find .onnx, tokens.txt, and espeak-ng-data in a model directory."""
p = Path(model_dir)
if not p.is_dir():
raise ValueError(f"Model directory not found: {model_dir}")
onnx_files = sorted(p.glob("*.onnx"))
if not onnx_files:
raise ValueError(f"No .onnx file found in {model_dir}")
onnx_file = next((path for path in onnx_files if path.is_file() and path.stat().st_size > 0), None)
if onnx_file is None:
raise ValueError(f"No non-empty .onnx file found in {model_dir}")
tokens = p / "tokens.txt"
if not tokens.is_file():
raise ValueError(f"tokens.txt not found in {model_dir}")
espeak_dir = p / "espeak-ng-data"
if not espeak_dir.is_dir():
raise ValueError(f"espeak-ng-data/ not found in {model_dir}")
return {
"model": onnx_file,
"tokens": tokens,
"data_dir": espeak_dir,
}
def _parse_voice(voice: str) -> tuple[str, int]:
"""Parse voice string into (model_dir, speaker_id).
Supports:
"/path/to/model" → (path, 0)
"/path/to/model:sid=50" → (path, 50)
"~/path/to/model:sid=50" → (expanded, 50)
"""
if ":sid=" in voice:
parts = voice.rsplit(":sid=", 1)
return str(Path(parts[0]).expanduser()), int(parts[1])
return str(Path(voice).expanduser()), 0
class SherpaTTS(TTSProvider):
"""Local TTS via sherpa-onnx-offline-tts binary.
Class name is SherpaTTS (not SherpaOnnxTTS) because podcastfy resolves
config keys via ClassName.lower().replace('tts','') → 'sherpa', which
must match the 'sherpa:' section in conversation.yaml.
"""
def __init__(self, api_key: Optional[str] = None, model: str = "sherpa"):
"""Initialize. api_key is ignored (local provider)."""
self.model = model # Required by podcastfy's text_to_speech.py
self.tts_bin = Path(os.environ.get("SHERPA_ONNX_TTS_BIN", str(DEFAULT_TTS_BIN))).expanduser()
if not self.tts_bin.exists():
raise RuntimeError(
f"sherpa-onnx-offline-tts not found at {self.tts_bin}. "
"Install from https://github.com/k2-fsa/sherpa-onnx/releases "
f"or set SHERPA_ONNX_TTS_BIN. Expected install path: {DEFAULT_TTS_BIN}"
)
if not os.access(self.tts_bin, os.X_OK):
raise RuntimeError(
f"sherpa-onnx-offline-tts is not executable: {self.tts_bin}. "
f"Run: chmod +x {self.tts_bin}"
)
def _resolve_timeout_seconds(self, text: str) -> int:
"""Resolve process timeout from env or text length."""
env_timeout = os.environ.get("SHERPA_TTS_TIMEOUT")
if env_timeout:
try:
timeout = int(env_timeout)
if timeout > 0:
return timeout
except ValueError:
pass
# Estimate ~20 chars/sec synth speed, clamped for long inputs.
dynamic_timeout = len(text) // 20
return max(60, min(600, dynamic_timeout))
def get_supported_tags(self) -> List[str]:
"""No SSML support for local TTS."""
return []
def generate_audio(self, text: str, voice: str, model: str, voice2: str = None) -> bytes:
"""Generate audio using sherpa-onnx-offline-tts.
Args:
text: Text to synthesize.
voice: Model directory path, optionally with ":sid=N" suffix.
model: Ignored (model is determined by voice path).
voice2: Ignored.
Returns:
WAV audio bytes.
"""
if not text or not text.strip():
raise ValueError("Text cannot be empty")
if not voice:
raise ValueError("Voice (model directory path) must be specified")
model_dir, sid = _parse_voice(voice)
files = _find_model_files(model_dir)
# Strip any SSML tags that might have leaked through
clean_text = re.sub(r"<[^>]+>", "", text).strip()
if not clean_text:
raise ValueError("Text is empty after stripping markup")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
cmd = [
str(self.tts_bin),
f"--vits-model={files['model']}",
f"--vits-tokens={files['tokens']}",
f"--vits-data-dir={files['data_dir']}",
f"--sid={sid}",
f"--output-filename={tmp_path}",
clean_text,
]
# Log command args without text content to avoid leaking sensitive input
cmd_args_str = " ".join(shlex.quote(str(part)) for part in cmd[:-1])
timeout_seconds = self._resolve_timeout_seconds(clean_text)
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"sherpa-onnx-offline-tts timed out after {timeout_seconds}s "
f"(text length: {len(clean_text)} chars). Args: {cmd_args_str}"
) from exc
if result.returncode != 0:
raise RuntimeError(
f"sherpa-onnx-offline-tts failed (rc={result.returncode}): "
f"{result.stderr[:500]} | Args: {cmd_args_str}"
)
with tmp_path.open("rb") as f:
wav_bytes = f.read()
if len(wav_bytes) <= WAV_HEADER_SIZE or len(wav_bytes) < MIN_AUDIO_BYTES:
raise RuntimeError(
"Generated audio file is suspiciously small "
f"({len(wav_bytes)} bytes, expected > {MIN_AUDIO_BYTES})"
)
return wav_bytes
finally:
try:
tmp_path.unlink()
except OSError:
pass