From a610ec32d05b4a2063e2e661feafe0cf43d2a89a Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Tue, 8 Sep 2026 17:35:30 +0200 Subject: [PATCH] feat(skills): add modular text-to-audio podcast producer skill --- AGENTS.md | 6 + skills/text-to-audio/SKILL.md | 99 ++++ skills/text-to-audio/scripts/text_to_audio.py | 427 ++++++++++++++++++ 3 files changed, 532 insertions(+) create mode 100644 skills/text-to-audio/SKILL.md create mode 100755 skills/text-to-audio/scripts/text_to_audio.py diff --git a/AGENTS.md b/AGENTS.md index da3ef9c..349493b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **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) Guidelines and scripts to help developers build high-quality, token-efficient Claude skills. diff --git a/skills/text-to-audio/SKILL.md b/skills/text-to-audio/SKILL.md new file mode 100644 index 0000000..fa6e44a --- /dev/null +++ b/skills/text-to-audio/SKILL.md @@ -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. diff --git a/skills/text-to-audio/scripts/text_to_audio.py b/skills/text-to-audio/scripts/text_to_audio.py new file mode 100755 index 0000000..c9a0f71 --- /dev/null +++ b/skills/text-to-audio/scripts/text_to_audio.py @@ -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: 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()