diff --git a/voice_nora.py b/voice_nora.py index 73e95ca..e37311d 100644 --- a/voice_nora.py +++ b/voice_nora.py @@ -1,142 +1,238 @@ import asyncio import os +import time +import traceback from pathlib import Path + import numpy as np import pyaudio +import webrtcvad from openwakeword.model import Model from faster_whisper import WhisperModel from edge_tts import Communicate -import wave -import webrtcvad # Für bessere Sprach-Erkennung from agent import Agent 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: def __init__(self): + logger.info("Initialisiere N.O.R.A...") + self.agent = Agent() - self.vad = webrtcvad.Vad(3) + self.running = True - # === Wakeword Modell mit korrektem Pfad === - model_dir = Path.home() / ".cache" / "openwakeword" / "models" + # ---- VAD ---- + self.vad = webrtcvad.Vad(VAD_MODE) - self.ww_model = Model( - wakeword="nora", - model_paths={ - "nora": str(model_dir / "hey_jarvis_v0.1.tflite") + # ---- Wakeword Model ---- + self.ww_model = self._load_wakeword_model() - }, - inference_framework="tflite" - ) + # ---- Whisper STT ---- + 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") - print(" Wakeword: 'Nora'") - print(" Weibliche Stimme aktiviert\n") + if not MODEL_PATH.exists(): + logger.error(f"Model nicht gefunden: {MODEL_PATH}") + 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() - stream = pa.open(format=pyaudio.paInt16, channels=1, rate=16000, - input=True, frames_per_buffer=1024) + stream = pa.open( + 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: - try: - audio_chunk = stream.read(1024, exception_on_overflow=False) - audio_np = np.frombuffer(audio_chunk, dtype=np.int16) + pa, stream = self._open_stream() - if self.ww_model.predict(audio_np).get("nora", 0) > 0.58: - print("\n✅ 'Nora' erkannt!") - await self.process_voice_command() - except: - pass + try: + while self.running: + audio = stream.read(FRAME_SIZE, exception_on_overflow=False) + audio_np = np.frombuffer(audio, dtype=np.int16) - stream.stop_stream() - stream.close() - pa.terminate() + try: + result = self.ww_model.predict(audio_np) - async def process_voice_command(self): - print("🎤 Ich höre zu... (sprich natürlich)") + score = max(result.values()) if result else 0.0 - 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 - print("❌ Zu kurz.") + except Exception as e: + 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 - # STT - segments, _ = self.stt_model.transcribe( - audio_data, language="de", beam_size=5, vad_filter=True - ) - text = " ".join(segment.text for segment in segments).strip() + text = await self.transcribe(audio) if not text: - print("❌ Konnte nichts verstehen.") + logger.warning("Keine Sprache erkannt") return - print(f"👤 Du: {text}") + logger.info(f"USER: {text}") response = await self.agent.run(text) - print(f"🗣️ N.O.R.A: {response}") + + logger.info(f"NORA: {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)""" - pa = pyaudio.PyAudio() - stream = pa.open(format=pyaudio.paInt16, channels=1, rate=16000, - input=True, frames_per_buffer=320) + # ========================= + # RECORDING (VAD) + # ========================= + async def record_until_silence(self): + pa, stream = self._open_stream() frames = [] - silence_count = 0 - max_silence = 25 # ca. 1,5 Sekunden Stille + silence = 0 - for _ in range(int(16000 / 320 * max_duration)): - chunk = stream.read(320, exception_on_overflow=False) + max_frames = int(SAMPLE_RATE / FRAME_SIZE * MAX_RECORD_SEC) + + for _ in range(max_frames): + chunk = stream.read(FRAME_SIZE, exception_on_overflow=False) frames.append(chunk) - # VAD prüfen - is_speech = self.vad.is_speech(chunk, 16000) - if not is_speech: - silence_count += 1 - else: - silence_count = 0 + is_speech = self.vad.is_speech(chunk, SAMPLE_RATE) - if silence_count > max_silence and len(frames) > 30: - break # aufhören wenn lange Stille + if is_speech: + silence = 0 + else: + silence += 1 + + if silence > SILENCE_LIMIT and len(frames) > 20: + break stream.stop_stream() stream.close() 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: - 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") - os.system("mpg123 -q response.mp3") + text = " ".join([s.text for s in segments]).strip() + return text 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(): - voice = VoiceNora() - try: - await voice.listen_for_wakeword() - except KeyboardInterrupt: - print("\n\n👋 N.O.R.A wird beendet.") - except Exception as e: - logger.error(str(e)) + nora = VoiceNora() + await nora.run() + if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file