#!/usr/bin/env python3 """speak.py — deutsche Sprachausgabe über Piper (lokal, offline, kostenlos). Warum Piper und nicht die Windows-Sprachausgabe: auf diesem Rechner ist KEINE deutsche SAPI-/OneCore-Stimme installiert (nur David/Zira/Mark, alle en-US) — deutscher Text käme mit englischer Aussprache heraus. Piper bringt eine eigene deutsche Stimme mit, läuft komplett lokal (passt zum Rest des Setups: WireGuard- only, Secrets in der ini, keine Cloud-Abhängigkeit) und kostet nichts. Aufruf: python tools/speak.py "Der Text" echo "Der Text" | python tools/speak.py python tools/speak.py --datei meldung.txt Rückgabe 0 = gesprochen, 1 = Fehler (fehlt Piper/Modell → Hinweis auf stderr). Fail-safe gedacht: ein Aufrufer im Bot soll bei fehlender Sprachausgabe weiterlaufen, nicht abstürzen. """ from __future__ import annotations import argparse import os import subprocess import sys import tempfile HERE = os.path.dirname(os.path.abspath(__file__)) def _finde(name: str, *starts: str) -> str: """Erste Datei `name` unterhalb der Startpfade. ⚠ Das Piper-ZIP entpackt eine Ebene tiefer als erwartet (`tools/piper/piper/piper.exe`) — fest verdrahtete Pfade brechen deshalb, sobald jemand anders entpackt oder das Layout sich mit einer neuen Version ändert. Suchen ist hier robuster als raten.""" for s in starts: if os.path.isfile(s): return s for wurzel, _dirs, dateien in os.walk(s): if name in dateien: return os.path.join(wurzel, name) return "" PIPER = _finde("piper.exe", os.path.join(HERE, "piper")) MODELL = _finde("de_DE-thorsten-medium.onnx", os.path.join(HERE, "piper")) def sprich(text: str, behalten: str | None = None) -> int: text = (text or "").strip() if not text: return 0 if not PIPER or not MODELL: print(f"Piper oder Stimme nicht gefunden unter {os.path.join(HERE, 'piper')}\n" f" piper.exe: {PIPER or 'FEHLT'}\n Stimme: {MODELL or 'FEHLT'}", file=sys.stderr) return 1 wav = behalten or os.path.join(tempfile.gettempdir(), "speak_out.wav") try: subprocess.run([PIPER, "--model", MODELL, "--output_file", wav], input=text.encode("utf-8"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=120) except Exception as e: print(f"Piper-Aufruf fehlgeschlagen: {e}", file=sys.stderr) return 1 if behalten: # nur erzeugen, nicht abspielen return 0 # Abspielen über den .NET-SoundPlayer — kein zusätzliches Paket nötig. ps = (f"(New-Object Media.SoundPlayer '{wav}').PlaySync()") try: subprocess.run(["powershell", "-NoProfile", "-Command", ps], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=300) except Exception as e: print(f"Wiedergabe fehlgeschlagen: {e}", file=sys.stderr) return 1 return 0 def main() -> None: p = argparse.ArgumentParser(description="Deutsche Sprachausgabe via Piper") p.add_argument("text", nargs="*", help="zu sprechender Text") p.add_argument("--datei", help="Text aus Datei lesen") p.add_argument("--wav", help="nur WAV erzeugen, nicht abspielen") a = p.parse_args() if a.datei: with open(a.datei, encoding="utf-8") as f: txt = f.read() elif a.text: txt = " ".join(a.text) else: txt = sys.stdin.read() # Pipe-Betrieb sys.exit(sprich(txt, a.wav)) if __name__ == "__main__": main()