fixing input device

This commit is contained in:
Matthias Hinrichs
2026-01-29 22:49:26 +01:00
parent 92a77ee944
commit a8a3ec9b9f
3 changed files with 72 additions and 71 deletions
+62 -70
View File
@@ -2,20 +2,38 @@ import os
import time import time
import asyncio import asyncio
import requests import requests
import struct
import math
import cv2
import pvporcupine
import pyaudio
from dotenv import load_dotenv, set_key from dotenv import load_dotenv, set_key
from fastapi import FastAPI, BackgroundTasks from fastapi import FastAPI, BackgroundTasks
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
import uvicorn import uvicorn
from ctypes import CFUNCTYPE, c_char_p, c_int, cdll
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
ENV_PATH = ".env" 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") app.mount("/static", StaticFiles(directory="static"), name="static")
class Config(BaseModel): class Config(BaseModel):
@@ -24,6 +42,7 @@ class Config(BaseModel):
brain_url: str brain_url: str
wake_word: str wake_word: str
custom_wake_word_path: str = "" custom_wake_word_path: str = ""
audio_index: str = ""
@app.get("/") @app.get("/")
async def read_index(): async def read_index():
@@ -31,7 +50,7 @@ async def read_index():
@app.get("/favicon.ico", include_in_schema=False) @app.get("/favicon.ico", include_in_schema=False)
async def favicon(): async def favicon():
return FileResponse("static/index.html") # Or just return 204 return FileResponse("static/index.html")
@app.get("/config") @app.get("/config")
async def get_config(): async def get_config():
@@ -40,45 +59,24 @@ async def get_config():
"pico_key": os.getenv("PICOVOICE_API_KEY", ""), "pico_key": os.getenv("PICOVOICE_API_KEY", ""),
"brain_url": os.getenv("BRAIN_URL", "http://localhost:8000"), "brain_url": os.getenv("BRAIN_URL", "http://localhost:8000"),
"wake_word": os.getenv("WAKE_WORD", "porcupine"), "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") @app.get("/check-brain")
async def check_brain(url: str): async def check_brain(url: str):
try: try:
# Simple health check to the brain response = requests.get(f"{url}/api/info", timeout=2)
# We use a short timeout (2s) to keep UI responsive
response = requests.get(f"{url}/docs", timeout=2)
return {"online": response.status_code == 200} return {"online": response.status_code == 200}
except Exception: except Exception:
return {"online": False} return {"online": False}
import cv2
def take_snapshot(): def take_snapshot():
cam_index = int(os.getenv("CAM_INDEX", 0)) cam_index = int(os.getenv("CAM_INDEX", 0))
cap = cv2.VideoCapture(cam_index) cap = cv2.VideoCapture(cam_index)
if not cap.isOpened(): if not cap.isOpened():
print("Error: Could not open camera.") print("Error: Could not open camera.")
return None return None
ret, frame = cap.read() ret, frame = cap.read()
if ret: if ret:
path = "snapshot.jpg" path = "snapshot.jpg"
@@ -90,46 +88,42 @@ def take_snapshot():
def send_to_brain(image_path): def send_to_brain(image_path):
brain_url = os.getenv("BRAIN_URL") brain_url = os.getenv("BRAIN_URL")
if not brain_url: if not brain_url: return None
return None
try: try:
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
files = {"file": 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: if response.status_code == 200:
return response.json() return response.json()
except Exception as e: except Exception as e:
print(f"Upload failed: {e}") print(f"Upload failed: {e}")
return None return None
import pvporcupine def list_audio_devices():
import pyaudio pa = pyaudio.PyAudio()
import struct 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(): def get_wake_word_params():
"""Helper to get Porcupine parameters based on settings."""
keyword = os.getenv("WAKE_WORD", "porcupine") keyword = os.getenv("WAKE_WORD", "porcupine")
custom_path = os.getenv("CUSTOM_WAKE_WORD_PATH", "") custom_path = os.getenv("CUSTOM_WAKE_WORD_PATH", "")
if keyword == "custom" and custom_path: if keyword == "custom" and custom_path:
return {"library_path": None, "model_path": None, "keyword_paths": [custom_path]} return {"keyword_paths": [custom_path]}
# Standard builtin keywords
if keyword in pvporcupine.KEYWORDS: if keyword in pvporcupine.KEYWORDS:
return {"keywords": [keyword]} return {"keywords": [keyword]}
return {"keywords": ["porcupine"]} return {"keywords": ["porcupine"]}
# Global variable to hold the task
interaction_task = None interaction_task = None
async def brain_interaction_loop(): async def brain_interaction_loop():
"""Background loop for wake-word detection and brain interaction.""" list_audio_devices()
# RE-LOAD settings inside the loop to ensure we use latest
access_key = os.getenv("PICOVOICE_API_KEY") access_key = os.getenv("PICOVOICE_API_KEY")
if not access_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 return
pa = None pa = None
@@ -139,28 +133,35 @@ async def brain_interaction_loop():
try: try:
params = get_wake_word_params() params = get_wake_word_params()
porcupine = pvporcupine.create(access_key=access_key, **params) porcupine = pvporcupine.create(access_key=access_key, **params)
pa = pyaudio.PyAudio() 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( audio_stream = pa.open(
rate=porcupine.sample_rate, rate=porcupine.sample_rate,
channels=1, channels=1,
format=pyaudio.paInt16, format=pyaudio.paInt16,
input=True, 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: while True:
# Check for cancellation
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
pcm = audio_stream.read(porcupine.frame_length, exception_on_overflow=False) 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!") print(f"[{os.getenv('BRO_NAME', 'Home-Bro')}] Wake-word detected!")
img_path = take_snapshot() img_path = take_snapshot()
if img_path: if img_path:
@@ -169,16 +170,13 @@ async def brain_interaction_loop():
print(f"Brain says: {result.get('comment')}") print(f"Brain says: {result.get('comment')}")
except asyncio.CancelledError: except asyncio.CancelledError:
print("Wake-word loop cancelled for restart...") print("Restarting Loop...")
except Exception as e: except Exception as e:
print(f"Error in wake-word loop: {e}") print(f"Error: {e}")
finally: finally:
if audio_stream is not None: if audio_stream: audio_stream.close()
audio_stream.close() if pa: pa.terminate()
if pa is not None: if porcupine: porcupine.delete()
pa.terminate()
if porcupine is not None:
porcupine.delete()
def start_interaction_loop(): def start_interaction_loop():
global interaction_task global interaction_task
@@ -188,30 +186,24 @@ async def restart_interaction_loop():
global interaction_task global interaction_task
if interaction_task: if interaction_task:
interaction_task.cancel() interaction_task.cancel()
try: try: await interaction_task
await interaction_task except asyncio.CancelledError: pass
except asyncio.CancelledError:
pass
start_interaction_loop() start_interaction_loop()
@app.post("/config") @app.post("/config")
async def save_config(config: Config, background_tasks: BackgroundTasks): async def save_config(config: Config, background_tasks: BackgroundTasks):
# Update environment variables in memory and file
settings = { settings = {
"BRO_NAME": config.bro_name, "BRO_NAME": config.bro_name,
"PICOVOICE_API_KEY": config.pico_key, "PICOVOICE_API_KEY": config.pico_key,
"BRAIN_URL": config.brain_url, "BRAIN_URL": config.brain_url,
"WAKE_WORD": config.wake_word, "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(): for key, value in settings.items():
os.environ[key] = value os.environ[key] = value
set_key(ENV_PATH, key, value) set_key(ENV_PATH, key, value)
# Trigger restart in the background
background_tasks.add_task(restart_interaction_loop) background_tasks.add_task(restart_interaction_loop)
return {"status": "ok"} return {"status": "ok"}
@app.on_event("startup") @app.on_event("startup")
+6
View File
@@ -61,6 +61,12 @@
<input type="text" id="custom-wake-word" placeholder="/path/to/file.ppn" style="display: none; margin-top: 10px;" /> <input type="text" id="custom-wake-word" placeholder="/path/to/file.ppn" style="display: none; margin-top: 10px;" />
</div> </div>
<div class="input-group">
<label for="audio-index">Audio Device Index</label>
<input type="number" id="audio-index" placeholder="z.B. 0" />
<span class="input-hint">Index des Mikrofons (siehe Konsolen-Log).</span>
</div>
<button id="save-btn" class="primary-btn"> <button id="save-btn" class="primary-btn">
<span class="btn-text">Einstellungen speichern</span> <span class="btn-text">Einstellungen speichern</span>
<span class="loader"></span> <span class="loader"></span>
+4 -1
View File
@@ -3,6 +3,7 @@ const picoKeyInput = document.getElementById('pico-key');
const brainUrlInput = document.getElementById('brain-url'); const brainUrlInput = document.getElementById('brain-url');
const wakeWordSelect = document.getElementById('wake-word'); const wakeWordSelect = document.getElementById('wake-word');
const customWakeWordInput = document.getElementById('custom-wake-word'); const customWakeWordInput = document.getElementById('custom-wake-word');
const audioIndexInput = document.getElementById('audio-index');
const saveBtn = document.getElementById('save-btn'); const saveBtn = document.getElementById('save-btn');
const statusBadge = document.getElementById('status-badge'); const statusBadge = document.getElementById('status-badge');
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');
@@ -22,6 +23,7 @@ async function fetchConfig() {
brainUrlInput.value = data.brain_url || ''; brainUrlInput.value = data.brain_url || '';
wakeWordSelect.value = data.wake_word || 'porcupine'; wakeWordSelect.value = data.wake_word || 'porcupine';
customWakeWordInput.value = data.custom_wake_word_path || ''; customWakeWordInput.value = data.custom_wake_word_path || '';
audioIndexInput.value = data.audio_index || '';
if (wakeWordSelect.value === 'custom') { if (wakeWordSelect.value === 'custom') {
customWakeWordInput.style.display = 'block'; customWakeWordInput.style.display = 'block';
@@ -66,7 +68,8 @@ saveBtn.addEventListener('click', async () => {
pico_key: picoKeyInput.value, pico_key: picoKeyInput.value,
brain_url: brainUrlInput.value, brain_url: brainUrlInput.value,
wake_word: wakeWordSelect.value, wake_word: wakeWordSelect.value,
custom_wake_word_path: customWakeWordInput.value custom_wake_word_path: customWakeWordInput.value,
audio_index: audioIndexInput.value
}; };
try { try {