first commit
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"request": "ja sowas von geil so heiß",
|
||||
"type": "query",
|
||||
"emotion": "whisper",
|
||||
"autoplay": true,
|
||||
"history": []
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
+156
@@ -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 <name> /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()
|
||||
+180
@@ -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 <think>...</think> Blöcke – die sollen nicht vorgelesen werden."""
|
||||
import re
|
||||
text = re.sub(r"<think>.*?</think>", "", 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 <think>-Block extra kostet
|
||||
temperature=0.7,
|
||||
stop=["<|im_end|>", "</s>"],
|
||||
)
|
||||
|
||||
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()
|
||||
+109
@@ -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()
|
||||
Reference in New Issue
Block a user