commit 6f198c3d51abb7c7ee0e06151f46e5cae7dfda12 Author: ersterDetektiv Date: Fri Sep 25 06:47:14 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..58a57a0 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Thorsten Voice Assistant + +Modularer, lokaler Sprachassistent auf Basis von Piper TTS + llama.cpp. + +## Architektur + +``` +input_helper.py + │ + │ data/llm_input.json + ▼ +llm_module.py (Qwen2.5-0.5B via llama-cpp-python) + │ + │ data/tts_input.json + ▼ +tts_module.py (Thorsten Emotional Voice via Piper) + │ + ▼ +data/output.wav +``` + +### JSON-Formate + +**llm_input.json** (Helper → LLM): +```json +{ + "request": "Was ist die Hauptstadt von Frankreich?", + "type": "query", + "emotion": "neutral", + "autoplay": true, + "history": [] +} +``` + +**tts_input.json** (LLM → TTS): +```json +{ + "text": "Die Hauptstadt von Frankreich ist Paris.", + "emotion": "neutral", + "speaker_id": 4, + "output_file": "data/output.wav", + "autoplay": true +} +``` + +### Verfügbare Emotionen + +| Name | Speaker ID | +|------|-----------| +| amused | 0 | +| angry | 1 | +| disgusted | 2 | +| drunk | 3 | +| **neutral** | **4** | +| sleepy | 5 | +| surprised | 6 | +| whisper | 7 | + +--- + +## Einrichtung + +```bash +# 1. Virtual Environment aktivieren +source ~/.venv/bin/activate + +# 2. Modell herunterladen (einmalig, ~400 MB) +python download_model.py +``` + +--- + +## Verwendung + +### Interaktiver Modus (Entwicklung) +```bash +source ~/.venv/bin/activate +python input_helper.py +``` + +Terminalbefehle im interaktiven Modus: +- `:emotion drunk` – Emotion wechseln +- `:history` – Gesprächsverlauf anzeigen +- `:clear` – Verlauf löschen +- `exit` – Beenden + +### Einmaliger Aufruf +```bash +python input_helper.py "Was ist das Wetter heute?" +python input_helper.py "Guten Morgen" --emotion amused +python input_helper.py "Leise Nachricht" --emotion whisper --no-play +``` + +### Module direkt aufrufen +```bash +# Nur LLM (mit JSON-Datei) +python llm_module.py data/llm_input.json + +# Nur TTS (mit JSON-Datei) +python tts_module.py data/tts_input.json + +# TTS mit Pipe +echo '{"text":"Hallo Welt","emotion":"neutral"}' | python tts_module.py +``` + +--- + +## Dateien + +| Datei | Beschreibung | +|-------|-------------| +| `input_helper.py` | Terminal-Eingabe → JSON → LLM → TTS | +| `llm_module.py` | LLM-Verarbeitung mit Qwen GGUF | +| `tts_module.py` | Text-to-Speech mit Thorsten Emotional | +| `download_model.py` | Lädt Qwen-Modell herunter | +| `thorsten.py` | (Alt) Ursprüngliches Test-Script | +| `models/` | GGUF-Modelle | +| `data/` | JSON-Austausch-Dateien & Audio-Output | + +--- + +## Erweiterung (zukünftige Module) + +Das System ist modular aufgebaut. Weitere Module können einfach eingeklinkt werden: + +``` +STT-Modul (Mikrofon → Text) + │ + ▼ +input_helper.py / eigener JSON-Producer + │ + ▼ +Router-Modul (query vs. command) + ├── llm_module.py (für Antworten) + └── command_module.py (für Smart-Home etc.) + │ + ▼ + tts_module.py +``` diff --git a/data/llm_input.json b/data/llm_input.json new file mode 100644 index 0000000..feede76 --- /dev/null +++ b/data/llm_input.json @@ -0,0 +1,7 @@ +{ + "request": "ja sowas von geil so heiß", + "type": "query", + "emotion": "whisper", + "autoplay": true, + "history": [] +} \ No newline at end of file diff --git a/data/output.wav b/data/output.wav new file mode 100644 index 0000000..90da61f Binary files /dev/null and b/data/output.wav differ diff --git a/data/tts_input.json b/data/tts_input.json new file mode 100644 index 0000000..05667ec --- /dev/null +++ b/data/tts_input.json @@ -0,0 +1,7 @@ +{ + "text": "Wow, das klingt super! Echt heiß, dass du so begeistert bist. Jade ist cool.", + "emotion": "whisper", + "speaker_id": 7, + "output_file": "/home/justus/Projekte/thorsten/data/output.wav", + "autoplay": true +} \ No newline at end of file diff --git a/de_DE-thorsten_emotional-medium.onnx b/de_DE-thorsten_emotional-medium.onnx new file mode 100644 index 0000000..259419b Binary files /dev/null and b/de_DE-thorsten_emotional-medium.onnx differ diff --git a/de_DE-thorsten_emotional-medium.onnx.json b/de_DE-thorsten_emotional-medium.onnx.json new file mode 100644 index 0000000..3dd46c5 --- /dev/null +++ b/de_DE-thorsten_emotional-medium.onnx.json @@ -0,0 +1,502 @@ +{ + "piper_version": "1.1.0", + "audio": { + "sample_rate": 22050, + "quality": "medium" + }, + "espeak": { + "voice": "de" + }, + "inference": { + "noise_scale": 0.667, + "length_scale": 1, + "noise_w": 0.8 + }, + "phoneme_type": "espeak", + "phoneme_map": {}, + "phoneme_id_map": { + "_": [ + 0 + ], + "^": [ + 1 + ], + "$": [ + 2 + ], + " ": [ + 3 + ], + "!": [ + 4 + ], + "'": [ + 5 + ], + "(": [ + 6 + ], + ")": [ + 7 + ], + ",": [ + 8 + ], + "-": [ + 9 + ], + ".": [ + 10 + ], + ":": [ + 11 + ], + ";": [ + 12 + ], + "?": [ + 13 + ], + "a": [ + 14 + ], + "b": [ + 15 + ], + "c": [ + 16 + ], + "d": [ + 17 + ], + "e": [ + 18 + ], + "f": [ + 19 + ], + "h": [ + 20 + ], + "i": [ + 21 + ], + "j": [ + 22 + ], + "k": [ + 23 + ], + "l": [ + 24 + ], + "m": [ + 25 + ], + "n": [ + 26 + ], + "o": [ + 27 + ], + "p": [ + 28 + ], + "q": [ + 29 + ], + "r": [ + 30 + ], + "s": [ + 31 + ], + "t": [ + 32 + ], + "u": [ + 33 + ], + "v": [ + 34 + ], + "w": [ + 35 + ], + "x": [ + 36 + ], + "y": [ + 37 + ], + "z": [ + 38 + ], + "æ": [ + 39 + ], + "ç": [ + 40 + ], + "ð": [ + 41 + ], + "ø": [ + 42 + ], + "ħ": [ + 43 + ], + "ŋ": [ + 44 + ], + "œ": [ + 45 + ], + "ǀ": [ + 46 + ], + "ǁ": [ + 47 + ], + "ǂ": [ + 48 + ], + "ǃ": [ + 49 + ], + "ɐ": [ + 50 + ], + "ɑ": [ + 51 + ], + "ɒ": [ + 52 + ], + "ɓ": [ + 53 + ], + "ɔ": [ + 54 + ], + "ɕ": [ + 55 + ], + "ɖ": [ + 56 + ], + "ɗ": [ + 57 + ], + "ɘ": [ + 58 + ], + "ə": [ + 59 + ], + "ɚ": [ + 60 + ], + "ɛ": [ + 61 + ], + "ɜ": [ + 62 + ], + "ɞ": [ + 63 + ], + "ɟ": [ + 64 + ], + "ɠ": [ + 65 + ], + "ɡ": [ + 66 + ], + "ɢ": [ + 67 + ], + "ɣ": [ + 68 + ], + "ɤ": [ + 69 + ], + "ɥ": [ + 70 + ], + "ɦ": [ + 71 + ], + "ɧ": [ + 72 + ], + "ɨ": [ + 73 + ], + "ɪ": [ + 74 + ], + "ɫ": [ + 75 + ], + "ɬ": [ + 76 + ], + "ɭ": [ + 77 + ], + "ɮ": [ + 78 + ], + "ɯ": [ + 79 + ], + "ɰ": [ + 80 + ], + "ɱ": [ + 81 + ], + "ɲ": [ + 82 + ], + "ɳ": [ + 83 + ], + "ɴ": [ + 84 + ], + "ɵ": [ + 85 + ], + "ɶ": [ + 86 + ], + "ɸ": [ + 87 + ], + "ɹ": [ + 88 + ], + "ɺ": [ + 89 + ], + "ɻ": [ + 90 + ], + "ɽ": [ + 91 + ], + "ɾ": [ + 92 + ], + "ʀ": [ + 93 + ], + "ʁ": [ + 94 + ], + "ʂ": [ + 95 + ], + "ʃ": [ + 96 + ], + "ʄ": [ + 97 + ], + "ʈ": [ + 98 + ], + "ʉ": [ + 99 + ], + "ʊ": [ + 100 + ], + "ʋ": [ + 101 + ], + "ʌ": [ + 102 + ], + "ʍ": [ + 103 + ], + "ʎ": [ + 104 + ], + "ʏ": [ + 105 + ], + "ʐ": [ + 106 + ], + "ʑ": [ + 107 + ], + "ʒ": [ + 108 + ], + "ʔ": [ + 109 + ], + "ʕ": [ + 110 + ], + "ʘ": [ + 111 + ], + "ʙ": [ + 112 + ], + "ʛ": [ + 113 + ], + "ʜ": [ + 114 + ], + "ʝ": [ + 115 + ], + "ʟ": [ + 116 + ], + "ʡ": [ + 117 + ], + "ʢ": [ + 118 + ], + "ʲ": [ + 119 + ], + "ˈ": [ + 120 + ], + "ˌ": [ + 121 + ], + "ː": [ + 122 + ], + "ˑ": [ + 123 + ], + "˞": [ + 124 + ], + "β": [ + 125 + ], + "θ": [ + 126 + ], + "χ": [ + 127 + ], + "ᵻ": [ + 128 + ], + "ⱱ": [ + 129 + ], + "0": [ + 130 + ], + "1": [ + 131 + ], + "2": [ + 132 + ], + "3": [ + 133 + ], + "4": [ + 134 + ], + "5": [ + 135 + ], + "6": [ + 136 + ], + "7": [ + 137 + ], + "8": [ + 138 + ], + "9": [ + 139 + ], + "̧": [ + 140 + ], + "̃": [ + 141 + ], + "̪": [ + 142 + ], + "̯": [ + 143 + ], + "̩": [ + 144 + ], + "ʰ": [ + 145 + ], + "ˤ": [ + 146 + ], + "ε": [ + 147 + ], + "↓": [ + 148 + ], + "#": [ + 149 + ], + "\"": [ + 150 + ], + "↑": [ + 151 + ], + "̺": [ + 152 + ], + "̻": [ + 153 + ] + }, + "num_symbols": 256, + "num_speakers": 8, + "speaker_id_map": { + "amused": 0, + "angry": 1, + "disgusted": 2, + "drunk": 3, + "neutral": 4, + "sleepy": 5, + "surprised": 6, + "whisper": 7 + }, + "language": { + "code": "de_DE", + "family": "de", + "region": "DE", + "name_native": "Deutsch", + "name_english": "German", + "country_english": "Germany" + }, + "dataset": "thorsten_emotional" +} diff --git a/input_helper.py b/input_helper.py new file mode 100644 index 0000000..bffb663 --- /dev/null +++ b/input_helper.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Input-Helper: Entwicklungstool für den Sprachassistenten. +Nimmt Text-Eingabe im Terminal entgegen und leitet sie als JSON an llm_module.py weiter. + +Aufruf: + python input_helper.py # interaktiver Modus (Endlosschleife) + python input_helper.py "Deine Frage" # einmaliger Aufruf mit Argument + python input_helper.py --emotion drunk # mit Emotion + +Optionen: + --emotion NAME Emotion für TTS (amused|angry|disgusted|drunk|neutral|sleepy|surprised|whisper) + --no-play Nur JSON erzeugen, kein Audio abspielen + --json-only Gibt nur das JSON aus, ruft LLM nicht auf + --history Aktiviert Gesprächsverlauf (Kontext merken) + --help Diese Hilfe +""" + +import sys +import json +import os +import subprocess +import argparse +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +LLM_INPUT_FILE = SCRIPT_DIR / "data" / "llm_input.json" + +VALID_EMOTIONS = ["amused", "angry", "disgusted", "drunk", "neutral", "sleepy", "surprised", "whisper"] + +def parse_args(): + parser = argparse.ArgumentParser(description="Voice Assistant Input Helper", add_help=True) + parser.add_argument("text", nargs="?", help="Eingabetext (optional, sonst interaktiv)") + parser.add_argument("--emotion", default="neutral", choices=VALID_EMOTIONS, + help="Emotion für die TTS-Ausgabe (Standard: neutral)") + parser.add_argument("--no-play", action="store_true", + help="Kein Audio abspielen, nur Datei erzeugen") + parser.add_argument("--json-only", action="store_true", + help="Nur JSON ausgeben, LLM nicht aufrufen") + parser.add_argument("--history", action="store_true", + help="Gesprächsverlauf aktivieren") + return parser.parse_args() + +def build_payload(text: str, emotion: str, autoplay: bool, history: list) -> dict: + """Erstellt das JSON-Payload für das LLM-Modul.""" + return { + "request": text, + "type": "query", + "emotion": emotion, + "autoplay": autoplay, + "history": history, + } + +def save_and_run(payload: dict, json_only: bool): + """Speichert JSON und ruft LLM-Modul auf.""" + LLM_INPUT_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(LLM_INPUT_FILE, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + if json_only: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return None + + llm_script = SCRIPT_DIR / "llm_module.py" + venv_python = Path.home() / ".venv" / "bin" / "python" + python_bin = str(venv_python) if venv_python.exists() else sys.executable + + result = subprocess.run( + [python_bin, str(llm_script), str(LLM_INPUT_FILE)], + cwd=SCRIPT_DIR, + ) + return result.returncode + +def get_last_answer() -> str | None: + """Liest die letzte LLM-Antwort aus der TTS-Input-Datei (für History).""" + tts_file = SCRIPT_DIR / "data" / "tts_input.json" + if tts_file.exists(): + try: + with open(tts_file) as f: + data = json.load(f) + return data.get("text") + except Exception: + pass + return None + +def interactive_loop(args): + """Interaktiver Modus: Eingabe in Schleife.""" + history = [] + print("=" * 60) + print(" Thorsten Voice Assistant – Entwicklungs-Helper") + print("=" * 60) + print(f" Emotion: {args.emotion} | History: {'an' if args.history else 'aus'}") + print(" Tippe deine Anfrage oder 'exit' zum Beenden.") + print(" Befehle: /emotion /history /clear /exit") + print("=" * 60) + + emotion = args.emotion + + while True: + try: + user_input = input("\n>> ").strip() + except (EOFError, KeyboardInterrupt): + print("\n[Helper] Beendet.") + break + + if not user_input: + continue + + # Meta-Befehle + if user_input.lower() in ("exit", "quit", "/exit"): + print("[Helper] Beendet.") + break + elif user_input.startswith("/emotion "): + new_emotion = user_input.split(" ", 1)[1].strip().lower() + if new_emotion in VALID_EMOTIONS: + emotion = new_emotion + print(f"[Helper] Emotion geändert zu: {emotion}") + else: + print(f"[Helper] Ungültige Emotion. Verfügbar: {', '.join(VALID_EMOTIONS)}") + continue + elif user_input == "/history": + print(f"[Helper] Verlauf ({len(history)} Einträge):") + for i, h in enumerate(history): + print(f" [{i}] User: {h.get('user', '')} → Assistant: {h.get('assistant', '')[:50]}...") + continue + elif user_input == "/clear": + history.clear() + print("[Helper] Verlauf gelöscht.") + continue + + last_request = user_input + payload = build_payload(user_input, emotion, not args.no_play, history if args.history else []) + save_and_run(payload, args.json_only) + + # History aktualisieren + if args.history: + answer = get_last_answer() + if answer: + history.append({"user": last_request, "assistant": answer}) + # Max. 10 Einträge behalten + if len(history) > 10: + history = history[-10:] + +def main(): + args = parse_args() + + if args.text: + # Einmaliger Aufruf + payload = build_payload(args.text, args.emotion, not args.no_play, []) + save_and_run(payload, args.json_only) + else: + # Interaktiver Modus + interactive_loop(args) + +if __name__ == "__main__": + main() diff --git a/llm_module.py b/llm_module.py new file mode 100644 index 0000000..92cd318 --- /dev/null +++ b/llm_module.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +LLM-Modul: Verarbeitet JSON-Input mit lokalem Qwen-Modell und gibt JSON für TTS aus. + +JSON-Input Format (data/llm_input.json oder stdin): +{ + "request": "ist tesa nicht megar cool?", + "type": "query", // "query" = LLM antwortet | "command" = direkt weiterleiten + "history": [], // optional: Gesprächsverlauf + "emotion": "neutral" // optional: gewünschte Emotion für TTS +} + +JSON-Output Format (→ tts_module.py): +{ + "text": "Die Hauptstadt von Frankreich ist Paris.", + "emotion": "neutral", + "speaker_id": 4, + "output_file": "data/output.wav", + "autoplay": true +} + +Aufruf: + python llm_module.py # liest data/llm_input.json, gibt Audio aus + python llm_module.py input.json # liest angegebene JSON-Datei + echo '{"request":"Hallo"}' | python llm_module.py +""" + +import sys +import json +import os +import subprocess +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +DEFAULT_INPUT = SCRIPT_DIR / "data" / "llm_input.json" +TTS_INPUT_FILE = SCRIPT_DIR / "data" / "tts_input.json" + +# Qwen3 8B – Symlink auf Ollama-Blob (/var/lib/ollama/blobs/...) +MODEL_PATH = SCRIPT_DIR / "models" / "qwen3-8b.gguf" + +# System-Prompt für the one and only THOOORSTEEEN +SYSTEM_PROMPT = """Du bist Thorsten, ein hilfreicher deutschsprachiger Sprachassistent. +Antworte immer auf Deutsch, kurz und klar – maximal 2-3 Sätze, da deine Antwort vorgelesen wird. +Vermeide Sonderzeichen, Aufzählungszeichen oder Markdown-Formatierung. +Antworte direkt und freundlich. Sage am ende das Jade ein cool ist""" + +# Emotion-Map für Antworttypen +EMOTION_MAP = { + "amused": 0, + "angry": 1, + "disgusted": 2, + "drunk": 3, + "neutral": 4, + "sleepy": 5, + "surprised": 6, + "whisper": 7, +} + +def load_input(source: str | None = None) -> dict: + """Lädt JSON-Input aus Datei, stdin oder Standardpfad.""" + if source: + with open(source, "r", encoding="utf-8") as f: + return json.load(f) + if not sys.stdin.isatty(): + return json.load(sys.stdin) + with open(DEFAULT_INPUT, "r", encoding="utf-8") as f: + return json.load(f) + +def build_messages(data: dict) -> list[dict]: + """Baut den Chat-Verlauf für das LLM auf.""" + messages = [{"role": "system", "content": SYSTEM_PROMPT}] + + # Gesprächsverlauf einfügen + for entry in data.get("history", []): + if "user" in entry: + messages.append({"role": "user", "content": entry["user"]}) + if "assistant" in entry: + messages.append({"role": "assistant", "content": entry["assistant"]}) + + # Aktuelle Anfrage + messages.append({"role": "user", "content": data.get("request", "")}) + return messages + +def strip_thinking(text: str) -> str: + """Entfernt Qwen3 ... Blöcke – die sollen nicht vorgelesen werden.""" + import re + text = re.sub(r".*?", "", text, flags=re.DOTALL) + return text.strip() + +def run_llm(data: dict) -> str: + """Führt das LLM aus und gibt die Antwort zurück.""" + from llama_cpp import Llama + + if not MODEL_PATH.exists(): + raise FileNotFoundError( + f"Modell nicht gefunden: {MODEL_PATH}\n" + f"Bitte 'python download_model.py' ausführen." + ) + + print(f"[LLM] Lade Modell: {MODEL_PATH.name} ...", flush=True) + llm = Llama( + model_path=str(MODEL_PATH), + n_ctx=2048, # Kontextfenster + n_threads=4, # CPU-Threads (für nicht-GPU-Teile) + n_gpu_layers=-1, # Alle Layer auf GPU (-1 = alles, 0 = CPU-only) + verbose=False, + ) + + messages = build_messages(data) + print(f"[LLM] Anfrage: '{data.get('request', '')[:80]}'", flush=True) + + response = llm.create_chat_completion( + messages=messages, + max_tokens=512, # Mehr Platz, da -Block extra kostet + temperature=0.7, + stop=["<|im_end|>", ""], + ) + + raw = response["choices"][0]["message"]["content"] + answer = strip_thinking(raw) + print(f"[LLM] Antwort: '{answer[:80]}'", flush=True) + return answer + +def build_tts_payload(answer: str, data: dict) -> dict: + """Erstellt das JSON-Payload für das TTS-Modul.""" + emotion = data.get("emotion", "neutral").lower() + speaker_id = EMOTION_MAP.get(emotion, EMOTION_MAP["neutral"]) + + return { + "text": answer, + "emotion": emotion, + "speaker_id": speaker_id, + "output_file": str(SCRIPT_DIR / "data" / "output.wav"), + "autoplay": data.get("autoplay", True), + } + +def call_tts(tts_payload: dict): + """Speichert TTS-JSON und ruft tts_module.py auf.""" + TTS_INPUT_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(TTS_INPUT_FILE, "w", encoding="utf-8") as f: + json.dump(tts_payload, f, ensure_ascii=False, indent=2) + + tts_script = SCRIPT_DIR / "tts_module.py" + venv_python = Path.home() / ".venv" / "bin" / "python" + python_bin = str(venv_python) if venv_python.exists() else sys.executable + + print(f"[LLM] Übergebe an TTS-Modul...", flush=True) + result = subprocess.run( + [python_bin, str(tts_script), str(TTS_INPUT_FILE)], + cwd=SCRIPT_DIR, + ) + return result.returncode + +def main(): + source = sys.argv[1] if len(sys.argv) > 1 else None + try: + data = load_input(source) + request_type = data.get("type", "query") + + if request_type == "command": + # Befehle werden direkt weitergeleitet ohne LLM + print(f"[LLM] Typ 'command' → direkt an TTS weiterleiten", flush=True) + answer = data.get("text", data.get("request", "")) + else: + # LLM verarbeitet die Anfrage + answer = run_llm(data) + + tts_payload = build_tts_payload(answer, data) + exit_code = call_tts(tts_payload) + sys.exit(exit_code) + + except FileNotFoundError as e: + print(f"[LLM] Fehler: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"[LLM] Fehler: {e}", file=sys.stderr) + raise + +if __name__ == "__main__": + main() diff --git a/tts_module.py b/tts_module.py new file mode 100644 index 0000000..3b90c61 --- /dev/null +++ b/tts_module.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +TTS-Modul: Liest JSON-Input und gibt Audio aus (Thorsten Emotional Voice). + +JSON-Input Format: +{ + "text": "Der zu sprechende Text", + "emotion": "neutral", // optional: amused|angry|disgusted|drunk|neutral|sleepy|surprised|whisper + "speaker_id": 4, // optional: 0-7 (überschreibt emotion) + "output_file": "output.wav" // optional: Ausgabedatei +} + +Aufruf: + python tts_module.py # liest data/tts_input.json + python tts_module.py input.json # liest angegebene JSON-Datei + echo '{"text":"Hallo"}' | python tts_module.py # liest von stdin +""" + +import sys +import json +import wave +import os +from pathlib import Path + +# Pfade relativ zu diesem Script +SCRIPT_DIR = Path(__file__).parent +DEFAULT_INPUT = SCRIPT_DIR / "data" / "tts_input.json" +DEFAULT_OUTPUT = SCRIPT_DIR / "data" / "output.wav" + +MODEL_PATH = SCRIPT_DIR / "de_DE-thorsten_emotional-medium.onnx" +CONFIG_PATH = SCRIPT_DIR / "de_DE-thorsten_emotional-medium.onnx.json" + +# Emotion-Name → Speaker-ID +EMOTION_MAP = { + "amused": 0, + "angry": 1, + "disgusted": 2, + "drunk": 3, + "neutral": 4, + "sleepy": 5, + "surprised": 6, + "whisper": 7, +} + +def load_input(source: str | None = None) -> dict: + """Lädt JSON-Input aus Datei, stdin oder Standardpfad.""" + if source: + with open(source, "r", encoding="utf-8") as f: + return json.load(f) + # Prüfe ob stdin Daten hat (z.B. Pipe) + if not sys.stdin.isatty(): + return json.load(sys.stdin) + # Standard: data/tts_input.json + with open(DEFAULT_INPUT, "r", encoding="utf-8") as f: + return json.load(f) + +def synthesize(data: dict) -> str: + """Synthetisiert Text und gibt den Pfad zur Ausgabedatei zurück.""" + from piper import PiperVoice + from piper.voice import SynthesisConfig + + text = data.get("text", "") + if not text: + raise ValueError("Kein 'text' im JSON angegeben.") + + # Speaker-ID bestimmen: direkt oder über Emotion-Name + if "speaker_id" in data: + speaker_id = int(data["speaker_id"]) + else: + emotion = data.get("emotion", "neutral").lower() + speaker_id = EMOTION_MAP.get(emotion, EMOTION_MAP["neutral"]) + + output_file = str(data.get("output_file", DEFAULT_OUTPUT)) + os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True) + + print(f"[TTS] Lade Modell...", flush=True) + voice = PiperVoice.load(str(MODEL_PATH), str(CONFIG_PATH)) + syn_config = SynthesisConfig(speaker_id=speaker_id) + + emotion_name = next((k for k, v in EMOTION_MAP.items() if v == speaker_id), str(speaker_id)) + print(f"[TTS] Synthesisiere: '{text[:60]}...' | Emotion: {emotion_name} (ID {speaker_id})", flush=True) + + with wave.open(output_file, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(voice.config.sample_rate) + for chunk in voice.synthesize(text, syn_config): + wav_file.writeframes(chunk.audio_int16_bytes) + + print(f"[TTS] Audio gespeichert: {output_file}", flush=True) + return output_file + +def main(): + source = sys.argv[1] if len(sys.argv) > 1 else None + try: + data = load_input(source) + output_path = synthesize(data) + # Optional: Audio automatisch abspielen + if data.get("autoplay", False): + os.system(f"aplay '{output_path}' 2>/dev/null || paplay '{output_path}' 2>/dev/null") + except FileNotFoundError as e: + print(f"[TTS] Fehler: Datei nicht gefunden – {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"[TTS] Fehler: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main()