feat(text-to-audio): switch to Gemini 3.1 Flash TTS, default to solo monologue, preselect DE/EN voices
This commit is contained in:
@@ -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.
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user