From 9ac1ee5f354782adb1ab49defa62cb2f8f896633 Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Tue, 8 Sep 2026 19:30:09 +0200 Subject: [PATCH] feat(text-to-audio): switch to Gemini 3.1 Flash TTS, default to solo monologue, preselect DE/EN voices --- skills/text-to-audio/SKILL.md | 107 ++++---- skills/text-to-audio/scripts/text_to_audio.py | 250 ++++++++++-------- 2 files changed, 184 insertions(+), 173 deletions(-) diff --git a/skills/text-to-audio/SKILL.md b/skills/text-to-audio/SKILL.md index fa6e44a..4e4ddb9 100644 --- a/skills/text-to-audio/SKILL.md +++ b/skills/text-to-audio/SKILL.md @@ -1,99 +1,82 @@ --- 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 -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 -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. +### 2. Dialog-Modus: Nur bei explizitem "Podcast"-Wunsch +* **NUR verwenden, wenn:** Der User explizit das Wort **"Podcast"** (oder "Zwei Moderatoren", "Dialog") verwendet! +* **Zwei Sprecher (Host & Co-Host):** + * **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. * **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). +### Redaktionelles Skripting +* **Wichtigkeit vor Quantität:** Kernaussagen und Aha-Momente priorisieren, Füllmaterial und Wiederholungen streichen. +* **Für das Ohr schreiben:** Keine Markdown-Artefakte (Sterne, Listen, rohe URLs, Tabellen). Klare akustische Übergänge nutzen. --- ## 🛠️ 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: -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: +### A. Solo-Modus (Standard) ```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 \ + --voice Puck \ + --lang 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`).* +### 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 -Das Skript gibt am Ende die Zeile `MEDIA:/tmp/...mp3` aus. -Übergib in deiner Antwort an Telegram: +Das Skript gibt am Ende `MEDIA:/tmp/...mp3` aus. Antworte in Telegram: ```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 ``` - -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 index c9a0f71..0faec61 100755 --- a/skills/text-to-audio/scripts/text_to_audio.py +++ b/skills/text-to-audio/scripts/text_to_audio.py @@ -2,8 +2,8 @@ """ 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. +High-quality speech synthesis using Google Gemini 3.1 Flash TTS (default) or other OpenRouter TTS models. +Produces single-speaker audio dossiers by default, and multi-speaker dialogue only when explicitly requested. """ import argparse @@ -14,35 +14,46 @@ import shutil import subprocess import sys import tempfile +import time import urllib.error import urllib.request +import wave 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" -# Speaker voice mapping presets +# Pre-selected voices for German and English 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", + "solo": "Puck", # Warm, lebendig, dynamisch (Standard für Solo Audio-Dossiers) + "host": "Puck", # Host im Dialog-Modus (nur bei expliziter "Podcast"-Anfrage) + "cohost": "Aoede", # Melodisch, klar, ausdrucksstark (Co-Host / Expertin) + "expert": "Aoede", + "male": "Puck", + "female": "Aoede", + "deep": "Charon", # Ruhig, tief, autoritär (Nachrichten/Doku) + "zephyr": "Zephyr", # Klar, ausgewogen, natürlich + "puck": "Puck", + "aoede": "Aoede", + "charon": "Charon", + "kore": "Kore", + "fenrir": "Fenrir", }, "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", + "solo": "Zephyr", # Clear, natural, balanced conversational male/neutral + "host": "Zephyr", # Host in dialog mode + "cohost": "Aoede", # Expressive, melodious, friendly female + "expert": "Aoede", + "male": "Zephyr", + "female": "Aoede", + "deep": "Charon", # Deep, resonant, calm male + "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.") -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.""" +def split_text_into_chunks(text: str, max_chars: int = 1200) -> list[str]: + """Split text into natural sentence-level chunks to satisfy API limits and natural pacing.""" 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 = [] @@ -101,23 +111,31 @@ def split_text_into_chunks(text: str, max_chars: int = 1500) -> list[str]: return chunks -def synthesize_speech( +def synthesize_segment( text: str, voice: str, output_path: str, model: str = DEFAULT_MODEL, api_key: str | None = None, + max_retries: int = 3, ) -> 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") if not key: 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 = { "model": model, "input": text, "voice": voice, - "response_format": "mp3", + "response_format": response_format, } data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( @@ -126,43 +144,57 @@ def synthesize_speech( headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", - "User-Agent": "OpenClaw-AudioDossier/1.0", + "User-Agent": "OpenClaw-TextToAudio/2.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 + last_error = None + for attempt in range(1, max_retries + 1): + try: + with urllib.request.urlopen(req, timeout=60) as resp: + content = resp.read() + if not content or len(content) < 100: + raise RuntimeError("Received empty or incomplete audio stream from TTS provider.") + + if is_gemini: + # 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: - """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 generate_silence_wav(duration_sec: float, output_path: str, sample_rate: int = 24000) -> None: + """Generate a clean silence WAV file using Python's standard library wave module.""" + num_frames = int(duration_sec * sample_rate) + silence_bytes = b"\x00\x00" * num_frames + with wave.open(output_path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sample_rate) + w.writeframes(silence_bytes) def stitch_audio_segments( @@ -173,12 +205,11 @@ def stitch_audio_segments( artist: str = "OpenClaw", album: str = "OpenClaw Audio Briefings", ) -> 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: raise ValueError("No audio segments to stitch.") if len(audio_files) == 1: - # Just copy and add metadata cmd = [ ffmpeg_bin, "-y", @@ -191,31 +222,17 @@ def stitch_audio_segments( "-metadata", f"album={album}", "-c:a", - "copy", + "libmp3lame", + "-b:a", + "128k", 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}") + raise RuntimeError(f"FFmpeg single-file export failed: {res.stderr}") return - # Multi-file concat using concat demuxer + # Multi-segment concat demuxer with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as list_file: for f in audio_files: safe_path = f.replace("'", "'\\''") @@ -241,7 +258,7 @@ def stitch_audio_segments( "-c:a", "libmp3lame", "-b:a", - "64k", + "128k", output_file, ] 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: - """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: 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: return lang_map[clean_name] - if clean_name.startswith("aura-"): - return clean_name + # Check common female or cohost roles + 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"]): - return lang_map.get("cohost", "aura-2-lara-de") + if any(k in clean_name for k in ["deep", "doku", "news", "charon"]): + return lang_map.get("deep", "Charon") - return lang_map.get("host", "aura-2-julius-de") + return lang_map.get("solo", "Puck") def process_dialog( @@ -279,12 +297,21 @@ def process_dialog( model: str, output_file: str, title: str, - pause_duration: float = 0.35, + pause_duration: float = 0.40, ) -> 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) + """Generate audio for structured multi-speaker dialog (only when 'podcast' is requested).""" + is_gemini = "gemini" in model.lower() + ext = "wav" if is_gemini else "mp3" + + 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 = [] seg_idx = 0 @@ -299,12 +326,12 @@ def process_dialog( 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) + chunk_file = os.path.join(tmpdir, f"seg_{seg_idx:04d}.{ext}") + synthesize_segment(chunk, voice=voice, output_path=chunk_file, model=model) segment_files.append(chunk_file) seg_idx += 1 - # Append silence between speaker turns + # Natural pause between speaker turns segment_files.append(silence_file) if segment_files and segment_files[-1] == silence_file: @@ -326,16 +353,19 @@ def process_solo_text( output_file: str, title: str, ) -> 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) if not chunks: 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: 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) + chunk_file = os.path.join(tmpdir, f"chunk_{i:04d}.{ext}") + synthesize_segment(chunk, voice=voice, output_path=chunk_file, model=model) segment_files.append(chunk_file) stitch_audio_segments( @@ -347,35 +377,33 @@ def process_solo_text( 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 = argparse.ArgumentParser(description="OpenClaw Text-to-Audio Dossier Generator") + parser.add_argument("--text", help="Plaintext to synthesize (default solo mode)") + parser.add_argument("--file", help="Path to plaintext file (default solo mode)") + 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 with dialog turns") 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("--voice", help="Voice override (Default DE: Puck, EN: Zephyr)") + 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("--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("--model", default=DEFAULT_MODEL, help=f"OpenRouter speech model (default: {DEFAULT_MODEL})") + 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: 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 mode only if explicit dialog file/json provided dialog_data = None if args.dialog_file: with open(args.dialog_file, "r", encoding="utf-8") as f: @@ -396,7 +424,7 @@ def main(): pause_duration=args.pause, ) else: - # 2. Solo text mode + # Solo text mode (Default) raw_text = "" if args.file: with open(args.file, "r", encoding="utf-8") as f: @@ -408,7 +436,7 @@ def main(): 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) + primary_voice = args.voice or resolve_voice("solo", lang=args.lang) process_solo_text( text=raw_text, ffmpeg_bin=ffmpeg_bin,