Update __main__.py

Replaced sounddevice with soundcard, removed the audio Queue, added the new download directory to the search path, and added the device listing arguments
This commit is contained in:
imonlinux
2025-11-17 20:21:24 -08:00
committed by GitHub
parent 005900b2ae
commit a4fa7b8631
+179 -97
View File
@@ -3,36 +3,31 @@ import argparse
import asyncio import asyncio
import json import json
import logging import logging
import sys
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from queue import Queue from typing import Dict, List, Optional, Set, Union
from typing import Dict, List, Optional, Union
import numpy as np import numpy as np
import sounddevice as sd import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .config import Config, load_config_from_json # <-- NEW from .config import Config, load_config_from_json
from .event_bus import EventBus, EventHandler, subscribe from .event_bus import EventBus, EventHandler, subscribe
from .led_controller import LedController from .led_controller import LedController
from .mqtt_controller import MqttController from .mqtt_controller import MqttController
from .microwakeword import MicroWakeWord, MicroWakeWordFeatures
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer from .mpv_player import MpvMediaPlayer
from .openwakeword import OpenWakeWord, OpenWakeWordFeatures
from .satellite import VoiceSatelliteProtocol from .satellite import VoiceSatelliteProtocol
from .util import get_mac, is_arm from .util import get_mac
from .zeroconf import HomeAssistantZeroconf from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent _MODULE_DIR = Path(__file__).parent
_REPO_DIR = _MODULE_DIR.parent _REPO_DIR = _MODULE_DIR.parent
if is_arm():
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
else:
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class MicMuteHandler(EventHandler): class MicMuteHandler(EventHandler):
@@ -54,12 +49,6 @@ class MicMuteHandler(EventHandler):
if is_muted: if is_muted:
self.state.event_bus.publish("mic_muted") self.state.event_bus.publish("mic_muted")
else: else:
_LOGGER.debug("Clearing stale audio queue...")
while not self.state.audio_queue.empty():
try:
self.state.audio_queue.get_nowait()
except Queue.Empty:
break
self.state.event_bus.publish("mic_unmuted") self.state.event_bus.publish("mic_unmuted")
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -71,21 +60,69 @@ async def main() -> None:
"-c", "-c",
"--config", "--config",
type=Path, type=Path,
required=True, required=False,
help="Path to configuration.json file", default=_MODULE_DIR / "config.json",
help="Path to configuration.json file (default: linux_voice_assistant/config.json)",
)
parser.add_argument(
"--debug",
action="store_true",
help="Enable debug logging (overrides config file)",
)
parser.add_argument(
"--list-input-devices",
action="store_true",
help="List audio input devices and exit",
)
parser.add_argument(
"--list-output-devices",
action="store_true",
help="List audio output devices and exit",
) )
args = parser.parse_args() args = parser.parse_args()
# --- Load Configuration --- # --- Handle List Devices ---
config = load_config_from_json(args.config) if args.list_input_devices:
print("Input devices")
print("=" * 13)
for idx, mic in enumerate(sc.all_microphones(include_loopback=False)):
print(f"[{idx}]", mic.name)
return
if args.list_output_devices:
player = MpvMediaPlayer(loop=None)
print("Output devices")
print("=" * 14)
try:
for speaker in player.player.audio_device_list:
print(speaker["name"] + ":", speaker["description"])
except Exception as e:
_LOGGER.error("Failed to list output devices: %s", e)
return
# --- Load Configuration ---
config_path = args.config
if not config_path.is_absolute():
config_path = _REPO_DIR / config_path
config = load_config_from_json(config_path)
if args.debug:
config.app.debug = True
# --- Setup Logging ---
logging.basicConfig(level=logging.DEBUG if config.app.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if config.app.debug else logging.INFO)
_LOGGER.info("Loading configuration from: %s", config_path)
_LOGGER.debug("Configuration loaded: %s", config) _LOGGER.debug("Configuration loaded: %s", config)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
event_bus = EventBus() event_bus = EventBus()
# --- Create Download Dir ---
download_dir = _REPO_DIR / config.wake_word.download_dir
download_dir.mkdir(parents=True, exist_ok=True)
_LOGGER.debug("Download directory: %s", download_dir)
# --- Load Preferences --- # --- Load Preferences ---
preferences_path = _REPO_DIR / config.app.preferences_file preferences_path = _REPO_DIR / config.app.preferences_file
if preferences_path.exists(): if preferences_path.exists():
@@ -95,51 +132,63 @@ async def main() -> None:
else: else:
preferences = Preferences() preferences = Preferences()
# Set default num_leds from config, allowing preferences to override
preferences.num_leds = getattr(preferences, 'num_leds', config.led.num_leds) preferences.num_leds = getattr(preferences, 'num_leds', config.led.num_leds)
# --- Load Wake Words --- # --- Load Wake Words ---
available_wake_words: Dict[str, AvailableWakeWord] = {} available_wake_words: Dict[str, AvailableWakeWord] = {}
# Add default directories if none are specified
if not config.wake_word.directories: if not config.wake_word.directories:
config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"] config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"]
for ww_dir_str in config.wake_word.directories: # Add download directory to search path
wake_word_dir = _REPO_DIR / ww_dir_str wake_word_dirs = [_REPO_DIR / d for d in config.wake_word.directories]
wake_word_dirs.append(download_dir / "external_wake_words") # <-- ADDED
for wake_word_dir in wake_word_dirs:
if not wake_word_dir.exists():
continue
for model_config_path in wake_word_dir.glob("*.json"): for model_config_path in wake_word_dir.glob("*.json"):
model_id = model_config_path.stem model_id = model_config_path.stem
if model_id == config.wake_word.stop_model: if model_id == config.wake_word.stop_model:
continue continue
with open(model_config_path, "r", encoding="utf-8") as model_config_file: with open(model_config_path, "r", encoding="utf-8") as model_config_file:
model_config = json.load(model_config_file) model_config = json.load(model_config_file)
model_type = model_config["type"] model_type = WakeWordType(model_config["type"])
if model_type == WakeWordType.OPEN_WAKE_WORD:
wake_word_path = model_config_path.parent / model_config["model"]
else:
wake_word_path = model_config_path
available_wake_words[model_id] = AvailableWakeWord( available_wake_words[model_id] = AvailableWakeWord(
id=model_id, id=model_id,
type=WakeWordType(model_type), type=model_type,
wake_word=model_config["wake_word"], wake_word=model_config["wake_word"],
trained_languages=model_config.get("trained_languages", []), trained_languages=model_config.get("trained_languages", []),
config_path=model_config_path, wake_word_path=wake_word_path,
) )
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys()))) _LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
# --- Load Active Wake Word Models --- # --- Load Active Wake Word Models ---
active_wake_words: Set[str] = set()
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {} wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
if preferences.active_wake_words: if preferences.active_wake_words:
for wake_word_id in preferences.active_wake_words: for wake_word_id in preferences.active_wake_words:
wake_word = available_wake_words.get(wake_word_id) wake_word = available_wake_words.get(wake_word_id)
if wake_word is None: continue if wake_word is None: continue
wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) _LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load()
active_wake_words.add(wake_word_id)
# Load default model if no preferences are set
if not wake_models: if not wake_models:
wake_word_id = config.wake_word.model wake_word_id = config.wake_word.model
wake_word = available_wake_words.get(wake_word_id) wake_word = available_wake_words.get(wake_word_id)
if wake_word: if wake_word:
wake_models[wake_word_id] = wake_word.load(libtensorflowlite_c_path) _LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load()
active_wake_words.add(wake_word_id)
# --- Load Stop Model --- # --- Load Stop Model ---
stop_model: Optional[MicroWakeWord] = None stop_model: Optional[MicroWakeWord] = None
@@ -147,10 +196,27 @@ async def main() -> None:
wake_word_dir = _REPO_DIR / ww_dir_str wake_word_dir = _REPO_DIR / ww_dir_str
stop_config_path = wake_word_dir / f"{config.wake_word.stop_model}.json" stop_config_path = wake_word_dir / f"{config.wake_word.stop_model}.json"
if not stop_config_path.exists(): continue if not stop_config_path.exists(): continue
stop_model = MicroWakeWord.from_config(stop_config_path, libtensorflowlite_c_path) _LOGGER.debug("Loading stop model: %s", stop_config_path)
stop_model = MicroWakeWord.from_config(stop_config_path)
break break
assert stop_model is not None assert stop_model is not None
# --- Resolve Microphone ---
if config.audio.input_device is not None:
try:
input_device_idx = int(config.audio.input_device)
mic = sc.all_microphones(include_loopback=False)[input_device_idx]
except (ValueError, IndexError):
mic = sc.get_microphone(config.audio.input_device)
else:
mic = sc.default_microphone()
if mic is None:
_LOGGER.critical("No microphone found.")
sys.exit(1)
_LOGGER.info("Using audio input device: %s", mic.name)
# --- Initialize Media Players --- # --- Initialize Media Players ---
music_player = MpvMediaPlayer( music_player = MpvMediaPlayer(
loop=loop, loop=loop,
@@ -167,10 +233,10 @@ async def main() -> None:
state = ServerState( state = ServerState(
name=config.app.name, name=config.app.name,
mac_address=get_mac(), mac_address=get_mac(),
audio_queue=Queue(),
entities=[], entities=[],
available_wake_words=available_wake_words, available_wake_words=available_wake_words,
wake_words=wake_models, wake_words=wake_models,
active_wake_words=active_wake_words,
stop_word=stop_model, stop_word=stop_model,
music_player=music_player, music_player=music_player,
tts_player=tts_player, tts_player=tts_player,
@@ -178,11 +244,9 @@ async def main() -> None:
timer_finished_sound=str(_REPO_DIR / config.app.timer_finished_sound), timer_finished_sound=str(_REPO_DIR / config.app.timer_finished_sound),
preferences=preferences, preferences=preferences,
preferences_path=preferences_path, preferences_path=preferences_path,
libtensorflowlite_c_path=libtensorflowlite_c_path,
event_bus=event_bus, event_bus=event_bus,
loop=loop, loop=loop,
oww_melspectrogram_path=(_REPO_DIR / config.wake_word.oww_melspectrogram_model), download_dir=download_dir, # <-- ADDED
oww_embedding_path=(_REPO_DIR / config.wake_word.oww_embedding_model),
refractory_seconds=config.wake_word.refractory_seconds, refractory_seconds=config.wake_word.refractory_seconds,
) )
@@ -210,12 +274,13 @@ async def main() -> None:
mic_mute_handler = MicMuteHandler(state, mqtt_controller) mic_mute_handler = MicMuteHandler(state, mqtt_controller)
# --- Start Audio Processing Thread --- # --- Start Audio Processing Thread ---
process_audio_thread = threading.Thread(target=process_audio, args=(state,), daemon=True) process_audio_thread = threading.Thread(
target=process_audio,
args=(state, mic, config.audio.input_block_size),
daemon=True
)
process_audio_thread.start() process_audio_thread.start()
def sd_callback(indata, _frames, _time, _status):
state.audio_queue.put_nowait(bytes(indata))
# --- Start ESPHome Server --- # --- Start ESPHome Server ---
server = await loop.create_server( server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state), lambda: VoiceSatelliteProtocol(state),
@@ -227,31 +292,22 @@ async def main() -> None:
# --- Run Forever --- # --- Run Forever ---
try: try:
input_device = config.audio.input_device if config.audio.input_device else "default" async with server:
with sd.RawInputStream( _LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port)
samplerate=16000, await server.serve_forever()
blocksize=config.audio.input_block_size,
device=input_device,
dtype="int16",
channels=1,
callback=sd_callback
):
async with server:
_LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port)
await server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
except sd.PortAudioError as e:
_LOGGER.critical("Failed to open audio input device '%s': %s", input_device, e)
finally: finally:
if mqtt_controller: if mqtt_controller:
mqtt_controller.stop() mqtt_controller.stop()
state.audio_queue.put_nowait(None)
state.mic_muted = True
process_audio_thread.join() process_audio_thread.join()
_LOGGER.debug("Server stopped") _LOGGER.debug("Server stopped")
def process_audio(state: ServerState): def process_audio(state: ServerState, mic, block_size: int):
"""Process audio chunks from the microphone in a separate thread."""
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = [] wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
micro_features: Optional[MicroWakeWordFeatures] = None micro_features: Optional[MicroWakeWordFeatures] = None
micro_inputs: List[np.ndarray] = [] micro_inputs: List[np.ndarray] = []
@@ -260,43 +316,69 @@ def process_audio(state: ServerState):
has_oww = False has_oww = False
last_active: Optional[float] = None last_active: Optional[float] = None
try: try:
while True: _LOGGER.debug("Opening audio input device: %s", mic.name)
if state.mic_muted: with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
time.sleep(0.1) while True:
continue if state.mic_muted:
audio_chunk = state.audio_queue.get() time.sleep(0.1)
if audio_chunk is None: break continue
if state.satellite is None: continue
if (not wake_words) or (state.wake_words_changed and state.wake_words): audio_chunk_array = mic_in.record(block_size).reshape(-1)
state.wake_words_changed = False audio_chunk = (
wake_words = [ww for ww in state.wake_words.values() if ww.is_active] (np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words) .astype("<i2")
if micro_features is None: .tobytes()
micro_features = MicroWakeWordFeatures(libtensorflowlite_c_path=state.libtensorflowlite_c_path) )
if has_oww and (oww_features is None):
oww_features = OpenWakeWordFeatures(melspectrogram_model=state.oww_melspectrogram_path, embedding_model=state.oww_embedding_path, libtensorflowlite_c_path=state.libtensorflowlite_c_path) if state.satellite is None:
try: continue
state.satellite.handle_audio(audio_chunk)
assert micro_features is not None if (not wake_words) or (state.wake_words_changed and state.wake_words):
micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk)) state.wake_words_changed = False
if has_oww: wake_words = [
assert oww_features is not None ww for ww in state.wake_words.values()
oww_inputs.clear(); oww_inputs.extend(oww_features.process_streaming(audio_chunk)) if ww.id in state.active_wake_words
for wake_word in wake_words: ]
activated = False has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
if isinstance(wake_word, MicroWakeWord): if micro_features is None:
if any(wake_word.process_streaming(mi) for mi in micro_inputs): activated = True micro_features = MicroWakeWordFeatures()
elif isinstance(wake_word, OpenWakeWord): if has_oww and (oww_features is None):
if any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): activated = True oww_features = OpenWakeWordFeatures.from_builtin()
if activated: try:
now = time.monotonic() state.satellite.handle_audio(audio_chunk)
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
state.satellite.wakeup(wake_word) assert micro_features is not None
last_active = now micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if any(state.stop_word.process_streaming(mi) for mi in micro_inputs) and state.stop_word.is_active:
state.satellite.stop() if has_oww:
except Exception: _LOGGER.exception("Unexpected error handling audio") assert oww_features is not None
except Exception: _LOGGER.exception("Unexpected error processing audio") oww_inputs.clear(); oww_inputs.extend(oww_features.process_streaming(audio_chunk))
for wake_word in wake_words:
activated = False
if isinstance(wake_word, MicroWakeWord):
if any(wake_word.process_streaming(mi) for mi in micro_inputs): activated = True
elif isinstance(wake_word, OpenWakeWord):
if any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): activated = True
if activated:
now = time.monotonic()
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
state.satellite.wakeup(wake_word)
last_active = now
stopped = False
for micro_input in micro_inputs:
if state.stop_word.process_streaming(micro_input):
stopped = True
if stopped and (state.stop_word.id in state.active_wake_words):
state.satellite.stop()
except Exception: _LOGGER.exception("Unexpected error handling audio")
except Exception as e:
_LOGGER.critical("Error in audio processing thread: %s", e)
state.loop.call_soon_threadsafe(state.loop.stop)
if __name__ == "__main__": if __name__ == "__main__":
print("--- __main__ block executing ---") print("--- __main__ block executing ---")