first commit

This commit is contained in:
ersterDetektiv
2026-09-25 06:47:14 +02:00
commit 6f198c3d51
9 changed files with 1100 additions and 0 deletions
+139
View File
@@ -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
```
+7
View File
@@ -0,0 +1,7 @@
{
"request": "ja sowas von geil so heiß",
"type": "query",
"emotion": "whisper",
"autoplay": true,
"history": []
}
BIN
View File
Binary file not shown.
+7
View File
@@ -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.
+502
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()