feat(text-to-audio): switch to Gemini 3.1 Flash TTS, default to solo monologue, preselect DE/EN voices

This commit is contained in:
Moritz Graf
2026-09-08 19:30:09 +02:00
parent a610ec32d0
commit 9ac1ee5f35
2 changed files with 184 additions and 173 deletions
+45 -62
View File
@@ -1,99 +1,82 @@
--- ---
name: text-to-audio 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. description: Transformiert Quelltexte, Recherchen, Dossiers, Artikel oder Notizen in ein hochwertiges Audio-Dossier. Erstellt standardmäßig ein Solo-Briefing mit natürlicher Stimme (Google Gemini 3.1 Flash TTS). Schaltet nur dann auf einen Zwei-Sprecher-Dialog um, wenn der Begriff "Podcast" explizit genannt wird.
--- ---
# 🎙️ Text-to-Audio Dossier & Podcast Producer # 🎙️ 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. Dieser Skill transformiert beliebige Quelltexte (Recherchen, Restaurant-Infos, Artikel, Notizen, Reports) in ein professionelles Audio-Dossier mit **Google Gemini 3.1 Flash TTS**.
--- ---
## 🧭 Kern-Workflow ## 🧭 Kern-Regeln für das Format
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. Standard-Modus: Solo-Monolog (DEFAULT)
* **Immer Standard für:** Audio-Dossiers, Audio-Briefings, Zusammenfassungen, "Lies mir das vor", "Erstelle ein Audio zu X", tägliche News.
* **Ein einzelner, charismatischer Sprecher:** Kein künstlicher Dialog, keine erfundenen Zwischenfragen. Ein flüssiger, strukturierter, gut erzählter Vortrag.
* **Vorausgewählte Stimmen:**
* **Deutsch:** `Puck` (lebendig, warm, dynamisch) oder `Charon` (tief, ruhig, sachlich).
* **Englisch:** `Zephyr` (natürlich, ausgewogen, klar).
### 1. Zieldauer & Wortbudget ermitteln ### 2. Dialog-Modus: Nur bei explizitem "Podcast"-Wunsch
Gesprochenes Deutsch / Englisch hat ein natürliches Sprechtempo von ca. **120–140 Wörtern pro Minute**. * **NUR verwenden, wenn:** Der User explizit das Wort **"Podcast"** (oder "Zwei Moderatoren", "Dialog") verwendet!
Ermittle das Wortbudget: * **Zwei Sprecher (Host & Co-Host):**
* **Kurz-Briefing (`2m`):** ca. 250–300 Wörter. * **Host:** `Puck` (moderiert an, leitet durch die Themen).
* **Co-Host / Expertin:** `Aoede` (bringt Details, Einschätzungen und Praxisbeispiele).
---
## ⏱️ Zieldauer & Pacing (~130 Wörter / Minute)
Ermittle die gewünschte Dauer oder nutze den Standard:
* **Kurz-Dossier (`2m`):** ca. 250–300 Wörter.
* **Standard-Dossier (`3m`–`4m`, Default):** ca. 400–550 Wörter. * **Standard-Dossier (`3m`–`4m`, Default):** ca. 400–550 Wörter.
* **Ausführlicher Deep Dive (`5m`–`8m`):** ca. 650–1.000 Wörter. * **Ausführlicher Deep Dive (`5m`–`8m`):** ca. 650–1.000 Wörter.
* *Formel:* `Zielminuten × 130 Wörter`.
### 2. Redaktionelle Wichtigkeits-Filterung (Preprocessing) ### Redaktionelles Skripting
Quelltexte sind für das Auge geschrieben, nicht für das Ohr. Bereite den Inhalt vor: * **Wichtigkeit vor Quantität:** Kernaussagen und Aha-Momente priorisieren, Füllmaterial und Wiederholungen streichen.
* **Wichtigkeits-Kompression:** Filter die 3 bis 5 wichtigsten Kernaussagen und Aha-Momente heraus. Streiche Nebensächlichkeiten, Tabellen, rohe URLs, Codeblöcke und Fußnoten. * **Für das Ohr schreiben:** Keine Markdown-Artefakte (Sterne, Listen, rohe URLs, Tabellen). Klare akustische Übergänge nutzen.
* **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 ## 🛠️ Ausführung & Synthese
Nutze das Synthese-Skript `scripts/text_to_audio.py`. Nutze das Skript `scripts/text_to_audio.py` (bzw. `/home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py` im Cluster).
### A. Für Dialog-Modus: ### A. Solo-Modus (Standard)
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 ```bash
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \ python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
--text "Dein aufbereitetes Skript..." \ --text "Dein aufbereitetes Skript..." \
--voice aura-2-julius-de \ --voice Puck \
--lang de \
--title "Titel des Dossiers" \ --title "Titel des Dossiers" \
--output /tmp/audio-dossier.mp3 --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`).* ### B. Dialog-Modus (NUR wenn "Podcast" explizit verlangt)
```bash
cat << 'JSON_EOF' > /tmp/podcast_dialog.json
[
{"speaker": "host", "text": "Willkommen zum Podcast. Heute sprechen wir über..."},
{"speaker": "cohost", "text": "Hi! Ja, das Thema ist super spannend, weil..."}
]
JSON_EOF
python3 /home/node/mop-skills/mop-skills/skills/text-to-audio/scripts/text_to_audio.py \
--dialog-file /tmp/podcast_dialog.json \
--lang de \
--title "Podcast Titel" \
--output /tmp/audio-dossier.mp3
```
--- ---
## 📱 Auslieferung an den User ## 📱 Auslieferung an den User
Das Skript gibt am Ende die Zeile `MEDIA:/tmp/...mp3` aus. Das Skript gibt am Ende `MEDIA:/tmp/...mp3` aus. Antworte in Telegram:
Übergib in deiner Antwort an Telegram:
```markdown ```markdown
Hier ist dein persönliches Audio-Dossier zu **[Thema]** ([Dauer] Min.): Hier ist dein Audio-Dossier zu **[Thema]** ([Dauer] Min.):
MEDIA:/tmp/audio-dossier.mp3 MEDIA:/tmp/audio-dossier.mp3
``` ```
Telegram bindet daraufhin automatisch den integrierten Podcast-Player ein.
+139 -111
View File
@@ -2,8 +2,8 @@
""" """
text_to_audio.py - OpenRouter Text-to-Speech & Podcast Synthesizer text_to_audio.py - OpenRouter Text-to-Speech & Podcast Synthesizer
Synthesizes single-speaker dossiers or multi-speaker dialog podcasts using OpenRouter TTS High-quality speech synthesis using Google Gemini 3.1 Flash TTS (default) or other OpenRouter TTS models.
(default: deepgram/aura-2) and stitches segments with ffmpeg into an MP3 file. Produces single-speaker audio dossiers by default, and multi-speaker dialogue only when explicitly requested.
""" """
import argparse import argparse
@@ -14,35 +14,46 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import wave
from pathlib import Path from pathlib import Path
DEFAULT_MODEL = "deepgram/aura-2" DEFAULT_MODEL = "google/gemini-3.1-flash-tts-preview"
FALLBACK_MODEL = "microsoft/mai-voice-2"
OPENROUTER_SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech" OPENROUTER_SPEECH_URL = "https://openrouter.ai/api/v1/audio/speech"
# Speaker voice mapping presets # Pre-selected voices for German and English
DEFAULT_VOICES = { DEFAULT_VOICES = {
"de": { "de": {
"host": "aura-2-julius-de", "solo": "Puck", # Warm, lebendig, dynamisch (Standard für Solo Audio-Dossiers)
"cohost": "aura-2-lara-de", "host": "Puck", # Host im Dialog-Modus (nur bei expliziter "Podcast"-Anfrage)
"expert": "aura-2-lara-de", "cohost": "Aoede", # Melodisch, klar, ausdrucksstark (Co-Host / Expertin)
"male": "aura-2-julius-de", "expert": "Aoede",
"female": "aura-2-lara-de", "male": "Puck",
"julius": "aura-2-julius-de", "female": "Aoede",
"lara": "aura-2-lara-de", "deep": "Charon", # Ruhig, tief, autoritär (Nachrichten/Doku)
"fabian": "aura-2-fabian-de", "zephyr": "Zephyr", # Klar, ausgewogen, natürlich
"viktoria": "aura-2-viktoria-de", "puck": "Puck",
"aoede": "Aoede",
"charon": "Charon",
"kore": "Kore",
"fenrir": "Fenrir",
}, },
"en": { "en": {
"host": "aura-2-zeus-en", "solo": "Zephyr", # Clear, natural, balanced conversational male/neutral
"cohost": "aura-2-athena-en", "host": "Zephyr", # Host in dialog mode
"expert": "aura-2-athena-en", "cohost": "Aoede", # Expressive, melodious, friendly female
"male": "aura-2-zeus-en", "expert": "Aoede",
"female": "aura-2-athena-en", "male": "Zephyr",
"zeus": "aura-2-zeus-en", "female": "Aoede",
"athena": "aura-2-athena-en", "deep": "Charon", # Deep, resonant, calm male
"apollo": "aura-2-apollo-en", "puck": "Puck", # Energetic, warm, youthful
"aoede": "Aoede",
"zephyr": "Zephyr",
"charon": "Charon",
"kore": "Kore",
}, },
} }
@@ -69,15 +80,14 @@ def find_ffmpeg() -> str:
raise RuntimeError("ffmpeg binary not found. Please ensure ffmpeg is installed or set FFMPEG_PATH.") 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]: def split_text_into_chunks(text: str, max_chars: int = 1200) -> list[str]:
"""Split long text into natural sentence-level chunks to satisfy API limits.""" """Split text into natural sentence-level chunks to satisfy API limits and natural pacing."""
text = text.strip() text = text.strip()
if not text: if not text:
return [] return []
if len(text) <= max_chars: if len(text) <= max_chars:
return [text] return [text]
# Split on sentence boundaries
sentences = re.split(r"(?<=[.?!;:\n])\s+", text) sentences = re.split(r"(?<=[.?!;:\n])\s+", text)
chunks = [] chunks = []
current = [] current = []
@@ -101,23 +111,31 @@ def split_text_into_chunks(text: str, max_chars: int = 1500) -> list[str]:
return chunks return chunks
def synthesize_speech( def synthesize_segment(
text: str, text: str,
voice: str, voice: str,
output_path: str, output_path: str,
model: str = DEFAULT_MODEL, model: str = DEFAULT_MODEL,
api_key: str | None = None, api_key: str | None = None,
max_retries: int = 3,
) -> None: ) -> None:
"""Call OpenRouter audio speech API and save MP3 response.""" """
Synthesizes speech using OpenRouter with automatic retries and format handling.
- For Gemini models: requests 'pcm' and writes standard 24kHz WAV.
- For other models (MiniMax, Microsoft, Grok): requests 'mp3' and writes MP3.
"""
key = api_key or os.environ.get("OPENROUTER_API_KEY") key = api_key or os.environ.get("OPENROUTER_API_KEY")
if not key: if not key:
raise ValueError("OPENROUTER_API_KEY environment variable is not set.") raise ValueError("OPENROUTER_API_KEY environment variable is not set.")
is_gemini = "gemini" in model.lower()
response_format = "pcm" if is_gemini else "mp3"
payload = { payload = {
"model": model, "model": model,
"input": text, "input": text,
"voice": voice, "voice": voice,
"response_format": "mp3", "response_format": response_format,
} }
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request( req = urllib.request.Request(
@@ -126,43 +144,57 @@ def synthesize_speech(
headers={ headers={
"Authorization": f"Bearer {key}", "Authorization": f"Bearer {key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "OpenClaw-AudioDossier/1.0", "User-Agent": "OpenClaw-TextToAudio/2.0",
}, },
method="POST", method="POST",
) )
try: last_error = None
with urllib.request.urlopen(req, timeout=60) as resp: for attempt in range(1, max_retries + 1):
content = resp.read() try:
with open(output_path, "wb") as f: with urllib.request.urlopen(req, timeout=60) as resp:
f.write(content) content = resp.read()
except urllib.error.HTTPError as e: if not content or len(content) < 100:
body = e.read().decode("utf-8", errors="replace") raise RuntimeError("Received empty or incomplete audio stream from TTS provider.")
raise RuntimeError(f"OpenRouter TTS API error {e.code}: {body}") from e
except Exception as e: if is_gemini:
raise RuntimeError(f"TTS request failed: {e}") from e # Wrap raw 24kHz s16le PCM into standard WAV
with wave.open(output_path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2) # 16-bit
w.setframerate(24000) # 24kHz
w.writeframes(content)
else:
with open(output_path, "wb") as f:
f.write(content)
return # Success!
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
last_error = f"HTTP {e.code}: {body}"
# Retry on 5xx or transient 429
if attempt < max_retries and e.code in (429, 500, 502, 503, 504):
time.sleep(1.5 * attempt)
continue
break
except Exception as e:
last_error = str(e)
if attempt < max_retries:
time.sleep(1.5 * attempt)
continue
break
raise RuntimeError(f"OpenRouter TTS failed after {max_retries} attempts: {last_error}")
def generate_silence(ffmpeg_bin: str, duration_sec: float, output_path: str) -> None: def generate_silence_wav(duration_sec: float, output_path: str, sample_rate: int = 24000) -> None:
"""Generate a short silence MP3 segment for natural conversational pauses.""" """Generate a clean silence WAV file using Python's standard library wave module."""
cmd = [ num_frames = int(duration_sec * sample_rate)
ffmpeg_bin, silence_bytes = b"\x00\x00" * num_frames
"-y", with wave.open(output_path, "wb") as w:
"-f", w.setnchannels(1)
"lavfi", w.setsampwidth(2)
"-i", w.setframerate(sample_rate)
f"anullsrc=r=24000:cl=mono", w.writeframes(silence_bytes)
"-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( def stitch_audio_segments(
@@ -173,12 +205,11 @@ def stitch_audio_segments(
artist: str = "OpenClaw", artist: str = "OpenClaw",
album: str = "OpenClaw Audio Briefings", album: str = "OpenClaw Audio Briefings",
) -> None: ) -> None:
"""Concatenate audio segments and apply ID3 tags.""" """Concatenate audio segments (WAV or MP3) and produce high-quality 128k MP3 with ID3 tags."""
if not audio_files: if not audio_files:
raise ValueError("No audio segments to stitch.") raise ValueError("No audio segments to stitch.")
if len(audio_files) == 1: if len(audio_files) == 1:
# Just copy and add metadata
cmd = [ cmd = [
ffmpeg_bin, ffmpeg_bin,
"-y", "-y",
@@ -191,31 +222,17 @@ def stitch_audio_segments(
"-metadata", "-metadata",
f"album={album}", f"album={album}",
"-c:a", "-c:a",
"copy", "libmp3lame",
"-b:a",
"128k",
output_file, output_file,
] ]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if res.returncode != 0: if res.returncode != 0:
# Fallback to re-encoding if copy fails raise RuntimeError(f"FFmpeg single-file export failed: {res.stderr}")
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 return
# Multi-file concat using concat demuxer # Multi-segment concat demuxer
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file: with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file:
for f in audio_files: for f in audio_files:
safe_path = f.replace("'", "'\\''") safe_path = f.replace("'", "'\\''")
@@ -241,7 +258,7 @@ def stitch_audio_segments(
"-c:a", "-c:a",
"libmp3lame", "libmp3lame",
"-b:a", "-b:a",
"64k", "128k",
output_file, output_file,
] ]
res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) res = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
@@ -253,7 +270,7 @@ def stitch_audio_segments(
def resolve_voice(speaker_name: str, lang: str = "de", explicit_voice: str | None = None) -> str: 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.""" """Resolve a speaker alias into a pre-selected voice name."""
if explicit_voice: if explicit_voice:
return explicit_voice return explicit_voice
@@ -263,13 +280,14 @@ def resolve_voice(speaker_name: str, lang: str = "de", explicit_voice: str | Non
if clean_name in lang_map: if clean_name in lang_map:
return lang_map[clean_name] return lang_map[clean_name]
if clean_name.startswith("aura-"): # Check common female or cohost roles
return clean_name if any(k in clean_name for k in ["cohost", "female", "lara", "expert", "guest", "aoede"]):
return lang_map.get("cohost", "Aoede")
if any(k in clean_name for k in ["host2", "cohost", "female", "lara", "expert", "guest"]): if any(k in clean_name for k in ["deep", "doku", "news", "charon"]):
return lang_map.get("cohost", "aura-2-lara-de") return lang_map.get("deep", "Charon")
return lang_map.get("host", "aura-2-julius-de") return lang_map.get("solo", "Puck")
def process_dialog( def process_dialog(
@@ -279,12 +297,21 @@ def process_dialog(
model: str, model: str,
output_file: str, output_file: str,
title: str, title: str,
pause_duration: float = 0.35, pause_duration: float = 0.40,
) -> None: ) -> None:
"""Generate audio for structured multi-speaker dialog.""" """Generate audio for structured multi-speaker dialog (only when 'podcast' is requested)."""
with tempfile.TemporaryDirectory(prefix="audio_dossier_") as tmpdir: is_gemini = "gemini" in model.lower()
silence_file = os.path.join(tmpdir, "pause.mp3") ext = "wav" if is_gemini else "mp3"
generate_silence(ffmpeg_bin, pause_duration, silence_file)
with tempfile.TemporaryDirectory(prefix="audio_dialog_") as tmpdir:
silence_file = os.path.join(tmpdir, f"pause.{ext}")
if is_gemini:
generate_silence_wav(pause_duration, silence_file)
else:
subprocess.run([
ffmpeg_bin, "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
"-t", str(pause_duration), "-c:a", "libmp3lame", "-b:a", "64k", silence_file
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
segment_files = [] segment_files = []
seg_idx = 0 seg_idx = 0
@@ -299,12 +326,12 @@ def process_dialog(
chunks = split_text_into_chunks(text) chunks = split_text_into_chunks(text)
for chunk in chunks: for chunk in chunks:
chunk_file = os.path.join(tmpdir, f"seg_{seg_idx:04d}.mp3") chunk_file = os.path.join(tmpdir, f"seg_{seg_idx:04d}.{ext}")
synthesize_speech(chunk, voice=voice, output_path=chunk_file, model=model) synthesize_segment(chunk, voice=voice, output_path=chunk_file, model=model)
segment_files.append(chunk_file) segment_files.append(chunk_file)
seg_idx += 1 seg_idx += 1
# Append silence between speaker turns # Natural pause between speaker turns
segment_files.append(silence_file) segment_files.append(silence_file)
if segment_files and segment_files[-1] == silence_file: if segment_files and segment_files[-1] == silence_file:
@@ -326,16 +353,19 @@ def process_solo_text(
output_file: str, output_file: str,
title: str, title: str,
) -> None: ) -> None:
"""Generate audio for a single speaker text.""" """Generate audio for a single speaker text (default for all audio dossiers)."""
chunks = split_text_into_chunks(text) chunks = split_text_into_chunks(text)
if not chunks: if not chunks:
raise ValueError("No text provided to synthesize.") raise ValueError("No text provided to synthesize.")
is_gemini = "gemini" in model.lower()
ext = "wav" if is_gemini else "mp3"
with tempfile.TemporaryDirectory(prefix="audio_solo_") as tmpdir: with tempfile.TemporaryDirectory(prefix="audio_solo_") as tmpdir:
segment_files = [] segment_files = []
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
chunk_file = os.path.join(tmpdir, f"chunk_{i:04d}.mp3") chunk_file = os.path.join(tmpdir, f"chunk_{i:04d}.{ext}")
synthesize_speech(chunk, voice=voice, output_path=chunk_file, model=model) synthesize_segment(chunk, voice=voice, output_path=chunk_file, model=model)
segment_files.append(chunk_file) segment_files.append(chunk_file)
stitch_audio_segments( stitch_audio_segments(
@@ -347,35 +377,33 @@ def process_solo_text(
def main(): def main():
parser = argparse.ArgumentParser(description="OpenClaw Text-to-Audio / Podcast Generator") parser = argparse.ArgumentParser(description="OpenClaw Text-to-Audio Dossier Generator")
parser.add_argument("--text", help="Plaintext to synthesize (solo mode)") parser.add_argument("--text", help="Plaintext to synthesize (default solo mode)")
parser.add_argument("--file", help="Path to plaintext file (solo mode)") parser.add_argument("--file", help="Path to plaintext file (default solo mode)")
parser.add_argument("--dialog-json", help="Raw JSON string containing dialog array: [{'speaker':'...','text':'...'}]") parser.add_argument("--dialog-json", help="JSON string of dialog turns (used only when 'podcast' style requested)")
parser.add_argument("--dialog-file", help="Path to JSON file containing dialog array") parser.add_argument("--dialog-file", help="Path to JSON file with dialog turns")
parser.add_argument("--output", "-o", help="Target output MP3 path") 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("--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("--voice", help="Voice override (Default DE: Puck, EN: Zephyr)")
parser.add_argument("--co-voice", help="Secondary speaker voice for dialog (e.g. aura-2-lara-de)") parser.add_argument("--co-voice", help="Secondary voice override for dialog (Default: Aoede)")
parser.add_argument("--lang", default="de", choices=["de", "en"], help="Language preset (de/en)") 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("--model", default=DEFAULT_MODEL, help=f"OpenRouter speech model (default: {DEFAULT_MODEL})")
parser.add_argument("--pause", type=float, default=0.35, help="Pause in seconds between dialog turns") parser.add_argument("--pause", type=float, default=0.40, help="Pause in seconds between dialog turns")
parser.add_argument("--media-tag", action="store_true", default=True, help="Print MEDIA:<path> tag to stdout") parser.add_argument("--media-tag", action="store_true", default=True, help="Print MEDIA:<path> tag to stdout")
args = parser.parse_args() args = parser.parse_args()
ffmpeg_bin = find_ffmpeg() ffmpeg_bin = find_ffmpeg()
# Determine output path
if args.output: if args.output:
out_path = Path(args.output).expanduser().resolve() out_path = Path(args.output).expanduser().resolve()
else: else:
import time
ts = int(time.time()) ts = int(time.time())
out_path = Path(f"/tmp/audio-dossier-{ts}.mp3") out_path = Path(f"/tmp/audio-dossier-{ts}.mp3")
out_path.parent.mkdir(parents=True, exist_ok=True) out_path.parent.mkdir(parents=True, exist_ok=True)
# 1. Dialog mode via file or json string # Dialog mode only if explicit dialog file/json provided
dialog_data = None dialog_data = None
if args.dialog_file: if args.dialog_file:
with open(args.dialog_file, "r", encoding="utf-8") as f: with open(args.dialog_file, "r", encoding="utf-8") as f:
@@ -396,7 +424,7 @@ def main():
pause_duration=args.pause, pause_duration=args.pause,
) )
else: else:
# 2. Solo text mode # Solo text mode (Default)
raw_text = "" raw_text = ""
if args.file: if args.file:
with open(args.file, "r", encoding="utf-8") as f: with open(args.file, "r", encoding="utf-8") as f:
@@ -408,7 +436,7 @@ def main():
else: else:
parser.error("Must provide either --text, --file, --dialog-json, --dialog-file, or pipe text to stdin.") 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) primary_voice = args.voice or resolve_voice("solo", lang=args.lang)
process_solo_text( process_solo_text(
text=raw_text, text=raw_text,
ffmpeg_bin=ffmpeg_bin, ffmpeg_bin=ffmpeg_bin,