feat(skills): add modular text-to-audio podcast producer skill

This commit is contained in:
Moritz Graf
2026-09-08 17:35:30 +02:00
parent 2a6709d5bc
commit a610ec32d0
3 changed files with 532 additions and 0 deletions
+6
View File
@@ -30,6 +30,12 @@ Real-time public transport information for Regensburg (RVV). Provides departures
- **Features:** Real-time departures with delay info, connection search, Stop ID resolver. - **Features:** Real-time departures with delay info, connection search, Stop ID resolver.
- **Tools:** Includes `rvv_query.py` for direct EFA API interaction. - **Tools:** Includes `rvv_query.py` for direct EFA API interaction.
### [text-to-audio](skills/text-to-audio/SKILL.md)
Audio dossier and podcast producer. Converts arbitrary research, summaries, notes, or articles into audio dossiers with duration target, importance compression, and solo or NotebookLM-style dialogue.
- **Features:** Target duration pacing (~130 words/min), importance filtering, solo monologue or multi-speaker dialogue.
- **Voices:** OpenRouter `deepgram/aura-2` with German voices (`aura-2-julius-de`, `aura-2-lara-de`) and English voices (`aura-2-zeus-en`, `aura-2-athena-en`).
- **Tools:** Includes `scripts/text_to_audio.py` for API synthesis, multi-voice stitching, and MP3 generation.
### [skill-creator](skills/skill-creator/SKILL.md) ### [skill-creator](skills/skill-creator/SKILL.md)
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills. Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
+99
View File
@@ -0,0 +1,99 @@
---
name: text-to-audio
description: Transformiert Quelltexte, Recherchen, Dossiers, Artikel oder Notizen in ein hochwertiges Audio-Dossier / Podcast. Unterstützt flexible Zieldauer (z.B. 2m, 5m, 10m), filtert Inhalte vorab nach Wichtigkeit (Pacing: ~130 Wörter/Minute) und generiert wahlweise Solo-Briefings oder lebendige Zwei-Sprecher-Dialoge (NotebookLM-Style) über OpenRouter TTS.
---
# 🎙️ Text-to-Audio Dossier & Podcast Producer
Dieser Skill fungiert als spezialisierter **Audio-Producer**. Er nimmt beliebige Quelltexte (Rechercheergebnisse, Dossiers, Notizen aus dem Knowledge-Wiki, tägliche Reports oder Artikel) entgegen, komprimiert sie nach Wichtigkeit auf eine gewünschte Hördauer und generiert eine fertige, abspielbare MP3.
---
## 🧭 Kern-Workflow
Wenn der User ein Audio-Dossier oder einen Podcast wünscht (z.B. *"Erstelle ein 3-Minuten Audio-Dossier über X"*, *"Mach einen Podcast zu Thema Y"*, *"Lies mir das als Audio vor"*), gehe strikt in folgenden 4 Schritten vor:
### 1. Zieldauer & Wortbudget ermitteln
Gesprochenes Deutsch / Englisch hat ein natürliches Sprechtempo von ca. **120–140 Wörtern pro Minute**.
Ermittle das Wortbudget:
* **Kurz-Briefing (`2m`):** ca. 250–300 Wörter.
* **Standard-Dossier (`3m`–`4m`, Default):** ca. 400–550 Wörter.
* **Ausführlicher Deep Dive (`5m`–`8m`):** ca. 650–1.000 Wörter.
* *Formel:* `Zielminuten × 130 Wörter`.
### 2. Redaktionelle Wichtigkeits-Filterung (Preprocessing)
Quelltexte sind für das Auge geschrieben, nicht für das Ohr. Bereite den Inhalt vor:
* **Wichtigkeits-Kompression:** Filter die 3 bis 5 wichtigsten Kernaussagen und Aha-Momente heraus. Streiche Nebensächlichkeiten, Tabellen, rohe URLs, Codeblöcke und Fußnoten.
* **Auditiver Schreibstil:** Formuliere in natürlicher, flüssiger gesprochener Sprache mit klaren Übergängen (*"Ein entscheidender Punkt hierbei ist..."*, *"Was viele überrascht: ..."*, *"Kurz gesagt: ..."*).
* **Keine Markdown-Artefakte:** Verwende im Skript keine Sterne (`**`), Listenstriche oder Links. Zahlen und Akronyme ausschreiben, wenn die Aussprache sonst unklar ist (z.B. "Kubernetes ein Punkt zweiunddreißig" statt "K8s 1.32").
### 3. Format-Entscheidung: Solo vs. Dialog (NotebookLM-Style)
#### A. Dialog-Modus (Empfohlen für Themen-Deep-Dives & Podcasts)
Zwei Sprecher spielen sich die Bälle zu:
* **Julius (`aura-2-julius-de`):** Host, moderiert an, stellt neugierige Fragen, zieht Zwischenfazits.
* **Lara (`aura-2-lara-de`):** Expertin / Co-Host, erklärt Zusammenhänge, bringt Hintergründe und Beispiele.
Schreibe das Skript als JSON-Array:
```json
[
{"speaker": "julius", "text": "Willkommen zum heutigen Audio-Dossier. Wir schauen uns heute das Thema X an. Lara, was ist da gerade los?"},
{"speaker": "lara", "text": "Hi Julius! Spannend ist vor allem, dass sich hier fundamental etwas geändert hat..."},
{"speaker": "julius", "text": "Das klingt nach einem großen Schritt. Wie wirkt sich das in der Praxis aus?"},
{"speaker": "lara", "text": "In der Praxis bedeutet das..."}
]
```
#### B. Solo-Modus (Empfohlen für schnelle Briefings & Reports)
Ein einzelner Erzähler führt durch das Dossier:
* Stimme: `aura-2-julius-de` (Deutsch) oder `aura-2-zeus-en` (Englisch).
---
## 🛠️ Ausführung & Synthese
Nutze das Synthese-Skript `scripts/text_to_audio.py`.
### A. Für Dialog-Modus:
1. Speichere das strukturierte Skript temporär:
```bash
cat << 'EOF' > /tmp/podcast_script.json
[
{"speaker": "julius", "text": "..."},
{"speaker": "lara", "text": "..."}
]
EOF
```
2. Generiere das Audio:
```bash
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
--dialog-file /tmp/podcast_script.json \
--title "Titel des Dossiers" \
--output /tmp/audio-dossier.mp3
```
### B. Für Solo-Modus:
```bash
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
--text "Dein aufbereitetes Skript..." \
--voice aura-2-julius-de \
--title "Titel des Dossiers" \
--output /tmp/audio-dossier.mp3
```
*(Hinweis: `scripts/text_to_audio.py` befindet sich relativ im Skill-Ordner, in OpenClaw erreichbar unter `/home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py` oder `./skills/text-to-audio/scripts/text_to_audio.py`).*
---
## 📱 Auslieferung an den User
Das Skript gibt am Ende die Zeile `MEDIA:/tmp/...mp3` aus.
Übergib in deiner Antwort an Telegram:
```markdown
Hier ist dein persönliches Audio-Dossier zu **[Thema]** ([Dauer] Min.):
MEDIA:/tmp/audio-dossier.mp3
```
Telegram bindet daraufhin automatisch den integrierten Podcast-Player ein.
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""
text_to_audio.py - OpenRouter Text-to-Speech & Podcast Synthesizer
Synthesizes single-speaker dossiers or multi-speaker dialog podcasts using OpenRouter TTS
(default: deepgram/aura-2) and stitches segments with ffmpeg into an MP3 file.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
DEFAULT_MODEL = "deepgram/aura-2"
OPENROUTER_SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech"
# Speaker voice mapping presets
DEFAULT_VOICES = {
"de": {
"host": "aura-2-julius-de",
"cohost": "aura-2-lara-de",
"expert": "aura-2-lara-de",
"male": "aura-2-julius-de",
"female": "aura-2-lara-de",
"julius": "aura-2-julius-de",
"lara": "aura-2-lara-de",
"fabian": "aura-2-fabian-de",
"viktoria": "aura-2-viktoria-de",
},
"en": {
"host": "aura-2-zeus-en",
"cohost": "aura-2-athena-en",
"expert": "aura-2-athena-en",
"male": "aura-2-zeus-en",
"female": "aura-2-athena-en",
"zeus": "aura-2-zeus-en",
"athena": "aura-2-athena-en",
"apollo": "aura-2-apollo-en",
},
}
def find_ffmpeg() -> str:
"""Locate the ffmpeg binary across common container and host locations."""
env_path = os.environ.get("FFMPEG_PATH")
if env_path and os.path.exists(env_path):
return env_path
which_path = shutil.which("ffmpeg")
if which_path:
return which_path
candidates = [
"/home/node/.openclaw/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/usr/bin/ffmpeg",
]
for c in candidates:
if os.path.exists(c) and os.access(c, os.X_OK):
return c
raise RuntimeError("ffmpeg binary not found. Please ensure ffmpeg is installed or set FFMPEG_PATH.")
def split_text_into_chunks(text: str, max_chars: int = 1500) -> list[str]:
"""Split long text into natural sentence-level chunks to satisfy API limits."""
text = text.strip()
if not text:
return []
if len(text) <= max_chars:
return [text]
# Split on sentence boundaries
sentences = re.split(r"(?<=[.?!;:\n])\s+", text)
chunks = []
current = []
current_len = 0
for s in sentences:
s = s.strip()
if not s:
continue
if current_len + len(s) + 1 > max_chars and current:
chunks.append(" ".join(current))
current = [s]
current_len = len(s)
else:
current.append(s)
current_len += len(s) + 1
if current:
chunks.append(" ".join(current))
return chunks
def synthesize_speech(
text: str,
voice: str,
output_path: str,
model: str = DEFAULT_MODEL,
api_key: str | None = None,
) -> None:
"""Call OpenRouter audio speech API and save MP3 response."""
key = api_key or os.environ.get("OPENROUTER_API_KEY")
if not key:
raise ValueError("OPENROUTER_API_KEY environment variable is not set.")
payload = {
"model": model,
"input": text,
"voice": voice,
"response_format": "mp3",
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
OPENROUTER_SPEECH_URL,
data=data,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"User-Agent": "OpenClaw-AudioDossier/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
content = resp.read()
with open(output_path, "wb") as f:
f.write(content)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"OpenRouter TTS API error {e.code}: {body}") from e
except Exception as e:
raise RuntimeError(f"TTS request failed: {e}") from e
def generate_silence(ffmpeg_bin: str, duration_sec: float, output_path: str) -> None:
"""Generate a short silence MP3 segment for natural conversational pauses."""
cmd = [
ffmpeg_bin,
"-y",
"-f",
"lavfi",
"-i",
f"anullsrc=r=24000:cl=mono",
"-t",
str(duration_sec),
"-c:a",
"libmp3lame",
"-b:a",
"48k",
output_path,
]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if res.returncode != 0:
raise RuntimeError(f"Failed to generate silence with ffmpeg: {res.stderr}")
def stitch_audio_segments(
ffmpeg_bin: str,
audio_files: list[str],
output_file: str,
title: str = "Audio Dossier",
artist: str = "OpenClaw",
album: str = "OpenClaw Audio Briefings",
) -> None:
"""Concatenate audio segments and apply ID3 tags."""
if not audio_files:
raise ValueError("No audio segments to stitch.")
if len(audio_files) == 1:
# Just copy and add metadata
cmd = [
ffmpeg_bin,
"-y",
"-i",
audio_files[0],
"-metadata",
f"title={title}",
"-metadata",
f"artist={artist}",
"-metadata",
f"album={album}",
"-c:a",
"copy",
output_file,
]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if res.returncode != 0:
# Fallback to re-encoding if copy fails
cmd_reencode = [
ffmpeg_bin,
"-y",
"-i",
audio_files[0],
"-metadata",
f"title={title}",
"-metadata",
f"artist={artist}",
"-metadata",
f"album={album}",
output_file,
]
res_re = subprocess.run(cmd_reencode, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if res_re.returncode != 0:
raise RuntimeError(f"FFmpeg error: {res_re.stderr}")
return
# Multi-file concat using concat demuxer
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file:
for f in audio_files:
safe_path = f.replace("'", "'\\''")
list_file.write(f"file '{safe_path}'\n")
list_file_path = list_file.name
try:
cmd = [
ffmpeg_bin,
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
list_file_path,
"-metadata",
f"title={title}",
"-metadata",
f"artist={artist}",
"-metadata",
f"album={album}",
"-c:a",
"libmp3lame",
"-b:a",
"64k",
output_file,
]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if res.returncode != 0:
raise RuntimeError(f"FFmpeg concatenation failed: {res.stderr}")
finally:
if os.path.exists(list_file_path):
os.remove(list_file_path)
def resolve_voice(speaker_name: str, lang: str = "de", explicit_voice: str | None = None) -> str:
"""Resolve a speaker name or role into a concrete OpenRouter voice ID."""
if explicit_voice:
return explicit_voice
lang_map = DEFAULT_VOICES.get(lang, DEFAULT_VOICES["de"])
clean_name = speaker_name.lower().strip()
if clean_name in lang_map:
return lang_map[clean_name]
if clean_name.startswith("aura-"):
return clean_name
if any(k in clean_name for k in ["host2", "cohost", "female", "lara", "expert", "guest"]):
return lang_map.get("cohost", "aura-2-lara-de")
return lang_map.get("host", "aura-2-julius-de")
def process_dialog(
dialog_items: list[dict],
ffmpeg_bin: str,
lang: str,
model: str,
output_file: str,
title: str,
pause_duration: float = 0.35,
) -> None:
"""Generate audio for structured multi-speaker dialog."""
with tempfile.TemporaryDirectory(prefix="audio_dossier_") as tmpdir:
silence_file = os.path.join(tmpdir, "pause.mp3")
generate_silence(ffmpeg_bin, pause_duration, silence_file)
segment_files = []
seg_idx = 0
for item in dialog_items:
speaker = item.get("speaker", "host")
text = item.get("text", "").strip()
if not text:
continue
voice = resolve_voice(speaker, lang=lang, explicit_voice=item.get("voice"))
chunks = split_text_into_chunks(text)
for chunk in chunks:
chunk_file = os.path.join(tmpdir, f"seg_{seg_idx:04d}.mp3")
synthesize_speech(chunk, voice=voice, output_path=chunk_file, model=model)
segment_files.append(chunk_file)
seg_idx += 1
# Append silence between speaker turns
segment_files.append(silence_file)
if segment_files and segment_files[-1] == silence_file:
segment_files.pop()
stitch_audio_segments(
ffmpeg_bin=ffmpeg_bin,
audio_files=segment_files,
output_file=output_file,
title=title,
)
def process_solo_text(
text: str,
ffmpeg_bin: str,
voice: str,
model: str,
output_file: str,
title: str,
) -> None:
"""Generate audio for a single speaker text."""
chunks = split_text_into_chunks(text)
if not chunks:
raise ValueError("No text provided to synthesize.")
with tempfile.TemporaryDirectory(prefix="audio_solo_") as tmpdir:
segment_files = []
for i, chunk in enumerate(chunks):
chunk_file = os.path.join(tmpdir, f"chunk_{i:04d}.mp3")
synthesize_speech(chunk, voice=voice, output_path=chunk_file, model=model)
segment_files.append(chunk_file)
stitch_audio_segments(
ffmpeg_bin=ffmpeg_bin,
audio_files=segment_files,
output_file=output_file,
title=title,
)
def main():
parser = argparse.ArgumentParser(description="OpenClaw Text-to-Audio / Podcast Generator")
parser.add_argument("--text", help="Plaintext to synthesize (solo mode)")
parser.add_argument("--file", help="Path to plaintext file (solo mode)")
parser.add_argument("--dialog-json", help="Raw JSON string containing dialog array: [{'speaker':'...','text':'...'}]")
parser.add_argument("--dialog-file", help="Path to JSON file containing dialog array")
parser.add_argument("--output", "-o", help="Target output MP3 path")
parser.add_argument("--title", default="Audio Dossier", help="Audio title for ID3 metadata")
parser.add_argument("--voice", help="Primary speaker voice (e.g. aura-2-julius-de)")
parser.add_argument("--co-voice", help="Secondary speaker voice for dialog (e.g. aura-2-lara-de)")
parser.add_argument("--lang", default="de", choices=["de", "en"], help="Language preset (de/en)")
parser.add_argument("--model", default=DEFAULT_MODEL, help="OpenRouter speech model")
parser.add_argument("--pause", type=float, default=0.35, help="Pause in seconds between dialog turns")
parser.add_argument("--media-tag", action="store_true", default=True, help="Print MEDIA:<path> tag to stdout")
args = parser.parse_args()
ffmpeg_bin = find_ffmpeg()
# Determine output path
if args.output:
out_path = Path(args.output).expanduser().resolve()
else:
import time
ts = int(time.time())
out_path = Path(f"/tmp/audio-dossier-{ts}.mp3")
out_path.parent.mkdir(parents=True, exist_ok=True)
# 1. Dialog mode via file or json string
dialog_data = None
if args.dialog_file:
with open(args.dialog_file, "r", encoding="utf-8") as f:
dialog_data = json.load(f)
elif args.dialog_json:
dialog_data = json.loads(args.dialog_json)
if dialog_data:
if not isinstance(dialog_data, list):
raise ValueError("Dialog input must be a JSON array of objects with 'speaker' and 'text'.")
process_dialog(
dialog_items=dialog_data,
ffmpeg_bin=ffmpeg_bin,
lang=args.lang,
model=args.model,
output_file=str(out_path),
title=args.title,
pause_duration=args.pause,
)
else:
# 2. Solo text mode
raw_text = ""
if args.file:
with open(args.file, "r", encoding="utf-8") as f:
raw_text = f.read()
elif args.text:
raw_text = args.text
elif not sys.stdin.isatty():
raw_text = sys.stdin.read()
else:
parser.error("Must provide either --text, --file, --dialog-json, --dialog-file, or pipe text to stdin.")
primary_voice = args.voice or resolve_voice("host", lang=args.lang)
process_solo_text(
text=raw_text,
ffmpeg_bin=ffmpeg_bin,
voice=primary_voice,
model=args.model,
output_file=str(out_path),
title=args.title,
)
print(f"Generated audio: {out_path} ({out_path.stat().st_size} bytes)")
if args.media_tag:
print(f"MEDIA:{out_path}")
if __name__ == "__main__":
main()