fixing input device
This commit is contained in:
+62
-70
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user