diff --git a/app/main.py b/app/main.py index 0998dc6..4b31eb1 100644 --- a/app/main.py +++ b/app/main.py @@ -2,20 +2,38 @@ import os import time import asyncio import requests +import struct +import math +import cv2 +import pvporcupine +import pyaudio from dotenv import load_dotenv, set_key from fastapi import FastAPI, BackgroundTasks from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from pydantic import BaseModel import uvicorn +from ctypes import CFUNCTYPE, c_char_p, c_int, cdll # Load environment variables load_dotenv() ENV_PATH = ".env" -app = FastAPI(title="Home-Bro Satellite") +# Logic to suppress ALSA/JACK error spam on Linux +def suppress_alsa_errors(): + def py_error_handler(filename, line, function, err, fmt): + pass + ERROR_HANDLER_FUNC = CFUNCTYPE(None, c_char_p, c_int, c_char_p, c_int, c_char_p) + c_error_handler = ERROR_HANDLER_FUNC(py_error_handler) + try: + asound = cdll.LoadLibrary('libasound.so.2') + asound.snd_lib_error_set_handler(c_error_handler) + except Exception: + pass -# Ensure static directory exists or serve it +suppress_alsa_errors() + +app = FastAPI(title="Home-Bro Satellite") app.mount("/static", StaticFiles(directory="static"), name="static") class Config(BaseModel): @@ -24,6 +42,7 @@ class Config(BaseModel): brain_url: str wake_word: str custom_wake_word_path: str = "" + audio_index: str = "" @app.get("/") async def read_index(): @@ -31,7 +50,7 @@ async def read_index(): @app.get("/favicon.ico", include_in_schema=False) async def favicon(): - return FileResponse("static/index.html") # Or just return 204 + return FileResponse("static/index.html") @app.get("/config") async def get_config(): @@ -40,45 +59,24 @@ async def get_config(): "pico_key": os.getenv("PICOVOICE_API_KEY", ""), "brain_url": os.getenv("BRAIN_URL", "http://localhost:8000"), "wake_word": os.getenv("WAKE_WORD", "porcupine"), - "custom_wake_word_path": os.getenv("CUSTOM_WAKE_WORD_PATH", "") + "custom_wake_word_path": os.getenv("CUSTOM_WAKE_WORD_PATH", ""), + "audio_index": os.getenv("AUDIO_DEVICE_INDEX", "") } -@app.post("/config") -async def save_config(config: Config): - # Update environment variables in memory and file - settings = { - "BRO_NAME": config.bro_name, - "PICOVOICE_API_KEY": config.pico_key, - "BRAIN_URL": config.brain_url, - "WAKE_WORD": config.wake_word, - "CUSTOM_WAKE_WORD_PATH": config.custom_wake_word_path - } - - for key, value in settings.items(): - os.environ[key] = value - set_key(ENV_PATH, key, value) - - return {"status": "ok"} - @app.get("/check-brain") async def check_brain(url: str): try: - # Simple health check to the brain - # We use a short timeout (2s) to keep UI responsive - response = requests.get(f"{url}/docs", timeout=2) + response = requests.get(f"{url}/api/info", timeout=2) return {"online": response.status_code == 200} except Exception: return {"online": False} -import cv2 - def take_snapshot(): cam_index = int(os.getenv("CAM_INDEX", 0)) cap = cv2.VideoCapture(cam_index) if not cap.isOpened(): print("Error: Could not open camera.") return None - ret, frame = cap.read() if ret: path = "snapshot.jpg" @@ -90,46 +88,42 @@ def take_snapshot(): def send_to_brain(image_path): brain_url = os.getenv("BRAIN_URL") - if not brain_url: - return None - + if not brain_url: return None try: with open(image_path, "rb") as f: files = {"file": f} - response = requests.post(f"{brain_url}/analyze", files=files) + response = requests.post(f"{brain_url}/analyze/pi", files=files) if response.status_code == 200: return response.json() except Exception as e: print(f"Upload failed: {e}") return None -import pvporcupine -import pyaudio -import struct +def list_audio_devices(): + pa = pyaudio.PyAudio() + print("\n--- Verfügbare Audiogeräte ---") + for i in range(pa.get_device_count()): + dev = pa.get_device_info_by_index(i) + print(f"Index {i}: {dev['name']} (Input Channels: {dev['maxInputChannels']})") + print("------------------------------\n") + pa.terminate() def get_wake_word_params(): - """Helper to get Porcupine parameters based on settings.""" keyword = os.getenv("WAKE_WORD", "porcupine") custom_path = os.getenv("CUSTOM_WAKE_WORD_PATH", "") - if keyword == "custom" and custom_path: - return {"library_path": None, "model_path": None, "keyword_paths": [custom_path]} - - # Standard builtin keywords + return {"keyword_paths": [custom_path]} if keyword in pvporcupine.KEYWORDS: return {"keywords": [keyword]} - return {"keywords": ["porcupine"]} -# Global variable to hold the task interaction_task = None async def brain_interaction_loop(): - """Background loop for wake-word detection and brain interaction.""" - # RE-LOAD settings inside the loop to ensure we use latest + list_audio_devices() access_key = os.getenv("PICOVOICE_API_KEY") if not access_key: - print("CRITICAL: PICOVOICE_API_KEY not found in .env. Wake-word detection disabled.") + print("CRITICAL: PICOVOICE_API_KEY Missing.") return pa = None @@ -139,28 +133,35 @@ async def brain_interaction_loop(): try: params = get_wake_word_params() porcupine = pvporcupine.create(access_key=access_key, **params) - pa = pyaudio.PyAudio() + + device_index = os.getenv("AUDIO_DEVICE_INDEX") + input_device_index = int(device_index) if device_index and device_index.strip() else None + audio_stream = pa.open( rate=porcupine.sample_rate, channels=1, format=pyaudio.paInt16, input=True, - frames_per_buffer=porcupine.frame_length + frames_per_buffer=porcupine.frame_length, + input_device_index=input_device_index ) - print(f"Listening for wake-word '{os.getenv('WAKE_WORD', 'porcupine')}'...") + print(f"Listening for '{os.getenv('WAKE_WORD', 'porcupine')}' on device {input_device_index}...") + frame_count = 0 while True: - # Check for cancellation await asyncio.sleep(0.01) - pcm = audio_stream.read(porcupine.frame_length, exception_on_overflow=False) - pcm = struct.unpack_from("h" * porcupine.frame_length, pcm) + pcm_unpacked = struct.unpack_from("h" * porcupine.frame_length, pcm) - keyword_index = porcupine.process(pcm) + frame_count += 1 + if frame_count % 100 == 0: + rms = math.sqrt(sum(x*x for x in pcm_unpacked) / len(pcm_unpacked)) + if rms < 10: + print(f"DEBUG: Pegel sehr niedrig ({rms:.2f}). Check Mic!") - if keyword_index >= 0: + if porcupine.process(pcm_unpacked) >= 0: print(f"[{os.getenv('BRO_NAME', 'Home-Bro')}] Wake-word detected!") img_path = take_snapshot() if img_path: @@ -169,16 +170,13 @@ async def brain_interaction_loop(): print(f"Brain says: {result.get('comment')}") except asyncio.CancelledError: - print("Wake-word loop cancelled for restart...") + print("Restarting Loop...") except Exception as e: - print(f"Error in wake-word loop: {e}") + print(f"Error: {e}") finally: - if audio_stream is not None: - audio_stream.close() - if pa is not None: - pa.terminate() - if porcupine is not None: - porcupine.delete() + if audio_stream: audio_stream.close() + if pa: pa.terminate() + if porcupine: porcupine.delete() def start_interaction_loop(): global interaction_task @@ -188,30 +186,24 @@ async def restart_interaction_loop(): global interaction_task if interaction_task: interaction_task.cancel() - try: - await interaction_task - except asyncio.CancelledError: - pass + try: await interaction_task + except asyncio.CancelledError: pass start_interaction_loop() @app.post("/config") async def save_config(config: Config, background_tasks: BackgroundTasks): - # Update environment variables in memory and file settings = { "BRO_NAME": config.bro_name, "PICOVOICE_API_KEY": config.pico_key, "BRAIN_URL": config.brain_url, "WAKE_WORD": config.wake_word, - "CUSTOM_WAKE_WORD_PATH": config.custom_wake_word_path + "CUSTOM_WAKE_WORD_PATH": config.custom_wake_word_path, + "AUDIO_DEVICE_INDEX": config.audio_index } - for key, value in settings.items(): os.environ[key] = value set_key(ENV_PATH, key, value) - - # Trigger restart in the background background_tasks.add_task(restart_interaction_loop) - return {"status": "ok"} @app.on_event("startup") diff --git a/static/index.html b/static/index.html index 9c72242..ec5ca33 100644 --- a/static/index.html +++ b/static/index.html @@ -61,6 +61,12 @@ +
+ + + Index des Mikrofons (siehe Konsolen-Log). +
+