voice_nora.py aktualisiert
This commit is contained in:
+175
-79
@@ -1,142 +1,238 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyaudio
|
import pyaudio
|
||||||
|
import webrtcvad
|
||||||
from openwakeword.model import Model
|
from openwakeword.model import Model
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
from edge_tts import Communicate
|
from edge_tts import Communicate
|
||||||
import wave
|
|
||||||
import webrtcvad # Für bessere Sprach-Erkennung
|
|
||||||
|
|
||||||
from agent import Agent
|
from agent import Agent
|
||||||
from util.logger import get_logger
|
from util.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger("VOICE")
|
logger = get_logger("NORA")
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# CONFIG
|
||||||
|
# =========================
|
||||||
|
WAKEWORD = "nora"
|
||||||
|
WAKE_THRESHOLD = 0.60
|
||||||
|
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
FRAME_SIZE = 1024
|
||||||
|
|
||||||
|
VAD_MODE = 3 # 0-3 (3 = aggressiv)
|
||||||
|
MAX_RECORD_SEC = 12
|
||||||
|
SILENCE_LIMIT = 25 # frames
|
||||||
|
|
||||||
|
TTS_VOICE = "de-DE-AmalaNeural"
|
||||||
|
|
||||||
|
MODEL_PATH = Path.home() / ".cache/openwakeword/models/hey_jarvis_v0.1.tflite"
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# VOICE ASSISTANT CORE
|
||||||
|
# =========================
|
||||||
class VoiceNora:
|
class VoiceNora:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
logger.info("Initialisiere N.O.R.A...")
|
||||||
|
|
||||||
self.agent = Agent()
|
self.agent = Agent()
|
||||||
self.vad = webrtcvad.Vad(3)
|
self.running = True
|
||||||
|
|
||||||
# === Wakeword Modell mit korrektem Pfad ===
|
# ---- VAD ----
|
||||||
model_dir = Path.home() / ".cache" / "openwakeword" / "models"
|
self.vad = webrtcvad.Vad(VAD_MODE)
|
||||||
|
|
||||||
self.ww_model = Model(
|
# ---- Wakeword Model ----
|
||||||
wakeword="nora",
|
self.ww_model = self._load_wakeword_model()
|
||||||
model_paths={
|
|
||||||
"nora": str(model_dir / "hey_jarvis_v0.1.tflite")
|
|
||||||
|
|
||||||
},
|
# ---- Whisper STT ----
|
||||||
inference_framework="tflite"
|
self.stt = WhisperModel(
|
||||||
)
|
"tiny",
|
||||||
|
device="cpu",
|
||||||
|
compute_type="int8"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("N.O.R.A bereit ✔")
|
||||||
|
logger.info(f"Wakeword: {WAKEWORD}")
|
||||||
|
logger.info(f"Model: {MODEL_PATH}")
|
||||||
|
|
||||||
# STT
|
# =========================
|
||||||
self.stt_model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
# INIT HELPERS
|
||||||
|
# =========================
|
||||||
|
def _load_wakeword_model(self):
|
||||||
|
logger.info("Lade Wakeword-Modell...")
|
||||||
|
|
||||||
print("\n🎤 Voice N.O.R.A gestartet")
|
if not MODEL_PATH.exists():
|
||||||
print(" Wakeword: 'Nora'")
|
logger.error(f"Model nicht gefunden: {MODEL_PATH}")
|
||||||
print(" Weibliche Stimme aktiviert\n")
|
raise FileNotFoundError(MODEL_PATH)
|
||||||
|
|
||||||
async def listen_for_wakeword(self):
|
try:
|
||||||
|
model = Model(
|
||||||
|
wakeword_models=[str(MODEL_PATH)]
|
||||||
|
)
|
||||||
|
logger.info("Wakeword-Modell geladen ✔")
|
||||||
|
return model
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Fehler beim Laden des Wakeword-Modells")
|
||||||
|
logger.error(str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# AUDIO STREAM
|
||||||
|
# =========================
|
||||||
|
def _open_stream(self):
|
||||||
pa = pyaudio.PyAudio()
|
pa = pyaudio.PyAudio()
|
||||||
stream = pa.open(format=pyaudio.paInt16, channels=1, rate=16000,
|
stream = pa.open(
|
||||||
input=True, frames_per_buffer=1024)
|
format=pyaudio.paInt16,
|
||||||
|
channels=1,
|
||||||
|
rate=SAMPLE_RATE,
|
||||||
|
input=True,
|
||||||
|
frames_per_buffer=FRAME_SIZE
|
||||||
|
)
|
||||||
|
return pa, stream
|
||||||
|
|
||||||
print("⏳ Lausche auf 'Nora'...")
|
# =========================
|
||||||
|
# MAIN LOOP
|
||||||
|
# =========================
|
||||||
|
async def run(self):
|
||||||
|
logger.info("Starte Audio-Loop...")
|
||||||
|
|
||||||
while self.running:
|
pa, stream = self._open_stream()
|
||||||
try:
|
|
||||||
audio_chunk = stream.read(1024, exception_on_overflow=False)
|
|
||||||
audio_np = np.frombuffer(audio_chunk, dtype=np.int16)
|
|
||||||
|
|
||||||
if self.ww_model.predict(audio_np).get("nora", 0) > 0.58:
|
try:
|
||||||
print("\n✅ 'Nora' erkannt!")
|
while self.running:
|
||||||
await self.process_voice_command()
|
audio = stream.read(FRAME_SIZE, exception_on_overflow=False)
|
||||||
except:
|
audio_np = np.frombuffer(audio, dtype=np.int16)
|
||||||
pass
|
|
||||||
|
|
||||||
stream.stop_stream()
|
try:
|
||||||
stream.close()
|
result = self.ww_model.predict(audio_np)
|
||||||
pa.terminate()
|
|
||||||
|
|
||||||
async def process_voice_command(self):
|
score = max(result.values()) if result else 0.0
|
||||||
print("🎤 Ich höre zu... (sprich natürlich)")
|
|
||||||
|
|
||||||
audio_data = await self.record_with_vad(max_duration=12)
|
if score > WAKE_THRESHOLD:
|
||||||
|
logger.info(f"Wakeword erkannt (score={score:.2f})")
|
||||||
|
await self.handle_command()
|
||||||
|
|
||||||
if len(audio_data) < 8000: # zu kurz
|
except Exception as e:
|
||||||
print("❌ Zu kurz.")
|
logger.error(f"Wakeword Fehler: {e}")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Beende N.O.R.A...")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
stream.stop_stream()
|
||||||
|
stream.close()
|
||||||
|
pa.terminate()
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# COMMAND FLOW
|
||||||
|
# =========================
|
||||||
|
async def handle_command(self):
|
||||||
|
logger.info("Aufnahme startet...")
|
||||||
|
|
||||||
|
audio = await self.record_until_silence()
|
||||||
|
|
||||||
|
if len(audio) < 8000:
|
||||||
|
logger.warning("Audio zu kurz")
|
||||||
return
|
return
|
||||||
|
|
||||||
# STT
|
text = await self.transcribe(audio)
|
||||||
segments, _ = self.stt_model.transcribe(
|
|
||||||
audio_data, language="de", beam_size=5, vad_filter=True
|
|
||||||
)
|
|
||||||
text = " ".join(segment.text for segment in segments).strip()
|
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
print("❌ Konnte nichts verstehen.")
|
logger.warning("Keine Sprache erkannt")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"👤 Du: {text}")
|
logger.info(f"USER: {text}")
|
||||||
|
|
||||||
response = await self.agent.run(text)
|
response = await self.agent.run(text)
|
||||||
print(f"🗣️ N.O.R.A: {response}")
|
|
||||||
|
logger.info(f"NORA: {response}")
|
||||||
|
|
||||||
await self.speak(response)
|
await self.speak(response)
|
||||||
|
|
||||||
async def record_with_vad(self, max_duration=12):
|
# =========================
|
||||||
"""Aufnahme mit Sprach-Erkennung (endet wenn du aufhörst zu sprechen)"""
|
# RECORDING (VAD)
|
||||||
pa = pyaudio.PyAudio()
|
# =========================
|
||||||
stream = pa.open(format=pyaudio.paInt16, channels=1, rate=16000,
|
async def record_until_silence(self):
|
||||||
input=True, frames_per_buffer=320)
|
pa, stream = self._open_stream()
|
||||||
|
|
||||||
frames = []
|
frames = []
|
||||||
silence_count = 0
|
silence = 0
|
||||||
max_silence = 25 # ca. 1,5 Sekunden Stille
|
|
||||||
|
|
||||||
for _ in range(int(16000 / 320 * max_duration)):
|
max_frames = int(SAMPLE_RATE / FRAME_SIZE * MAX_RECORD_SEC)
|
||||||
chunk = stream.read(320, exception_on_overflow=False)
|
|
||||||
|
for _ in range(max_frames):
|
||||||
|
chunk = stream.read(FRAME_SIZE, exception_on_overflow=False)
|
||||||
frames.append(chunk)
|
frames.append(chunk)
|
||||||
|
|
||||||
# VAD prüfen
|
is_speech = self.vad.is_speech(chunk, SAMPLE_RATE)
|
||||||
is_speech = self.vad.is_speech(chunk, 16000)
|
|
||||||
if not is_speech:
|
|
||||||
silence_count += 1
|
|
||||||
else:
|
|
||||||
silence_count = 0
|
|
||||||
|
|
||||||
if silence_count > max_silence and len(frames) > 30:
|
if is_speech:
|
||||||
break # aufhören wenn lange Stille
|
silence = 0
|
||||||
|
else:
|
||||||
|
silence += 1
|
||||||
|
|
||||||
|
if silence > SILENCE_LIMIT and len(frames) > 20:
|
||||||
|
break
|
||||||
|
|
||||||
stream.stop_stream()
|
stream.stop_stream()
|
||||||
stream.close()
|
stream.close()
|
||||||
pa.terminate()
|
pa.terminate()
|
||||||
|
|
||||||
return b''.join(frames)
|
return b"".join(frames)
|
||||||
|
|
||||||
async def speak(self, text: str):
|
# =========================
|
||||||
"""Edge TTS - weibliche Stimme"""
|
# STT
|
||||||
|
# =========================
|
||||||
|
async def transcribe(self, audio_bytes):
|
||||||
try:
|
try:
|
||||||
communicate = Communicate(text, voice="de-DE-AmalaNeural") # Weiblich, natürlich
|
segments, _ = self.stt.transcribe(
|
||||||
|
audio_bytes,
|
||||||
|
language="de",
|
||||||
|
beam_size=5
|
||||||
|
)
|
||||||
|
|
||||||
await communicate.save("response.mp3")
|
text = " ".join([s.text for s in segments]).strip()
|
||||||
os.system("mpg123 -q response.mp3")
|
return text
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"TTS Fehler: {e}")
|
logger.error("STT Fehler")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# TTS
|
||||||
|
# =========================
|
||||||
|
async def speak(self, text: str):
|
||||||
|
try:
|
||||||
|
filename = f"response_{int(time.time())}.mp3"
|
||||||
|
|
||||||
|
tts = Communicate(text, voice=TTS_VOICE)
|
||||||
|
await tts.save(filename)
|
||||||
|
|
||||||
|
os.system(f"mpg123 -q {filename}")
|
||||||
|
|
||||||
|
os.remove(filename)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("TTS Fehler")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# MAIN
|
||||||
|
# =========================
|
||||||
async def main():
|
async def main():
|
||||||
voice = VoiceNora()
|
nora = VoiceNora()
|
||||||
try:
|
await nora.run()
|
||||||
await voice.listen_for_wakeword()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n👋 N.O.R.A wird beendet.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(str(e))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user