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