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
+147 -65
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"
with sd.RawInputStream(
samplerate=16000,
blocksize=config.audio.input_block_size,
device=input_device,
dtype="int16",
channels=1,
callback=sd_callback
):
async with server: async with server:
_LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port) _LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port)
await server.serve_forever() 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:
_LOGGER.debug("Opening audio input device: %s", mic.name)
with mic.recorder(samplerate=16000, channels=1, blocksize=block_size) as mic_in:
while True: while True:
if state.mic_muted: if state.mic_muted:
time.sleep(0.1) time.sleep(0.1)
continue continue
audio_chunk = state.audio_queue.get()
if audio_chunk is None: break audio_chunk_array = mic_in.record(block_size).reshape(-1)
if state.satellite is None: continue audio_chunk = (
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
.astype("<i2")
.tobytes()
)
if state.satellite is None:
continue
if (not wake_words) or (state.wake_words_changed and state.wake_words): if (not wake_words) or (state.wake_words_changed and state.wake_words):
state.wake_words_changed = False state.wake_words_changed = False
wake_words = [ww for ww in state.wake_words.values() if ww.is_active] wake_words = [
ww for ww in state.wake_words.values()
if ww.id in state.active_wake_words
]
has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words) has_oww = any(isinstance(ww, OpenWakeWord) for ww in wake_words)
if micro_features is None: if micro_features is None:
micro_features = MicroWakeWordFeatures(libtensorflowlite_c_path=state.libtensorflowlite_c_path) micro_features = MicroWakeWordFeatures()
if has_oww and (oww_features is None): 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) oww_features = OpenWakeWordFeatures.from_builtin()
try: try:
state.satellite.handle_audio(audio_chunk) state.satellite.handle_audio(audio_chunk)
assert micro_features is not None assert micro_features is not None
micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk)) micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if has_oww: if has_oww:
assert oww_features is not None assert oww_features is not None
oww_inputs.clear(); oww_inputs.extend(oww_features.process_streaming(audio_chunk)) oww_inputs.clear(); oww_inputs.extend(oww_features.process_streaming(audio_chunk))
for wake_word in wake_words: for wake_word in wake_words:
activated = False activated = False
if isinstance(wake_word, MicroWakeWord): if isinstance(wake_word, MicroWakeWord):
if any(wake_word.process_streaming(mi) for mi in micro_inputs): activated = True if any(wake_word.process_streaming(mi) for mi in micro_inputs): activated = True
elif isinstance(wake_word, OpenWakeWord): 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 any(p > 0.5 for oi in oww_inputs for p in wake_word.process_streaming(oi)): activated = True
if activated: if activated:
now = time.monotonic() now = time.monotonic()
if (last_active is None) or ((now - last_active) > state.refractory_seconds): if (last_active is None) or ((now - last_active) > state.refractory_seconds):
state.satellite.wakeup(wake_word) state.satellite.wakeup(wake_word)
last_active = now last_active = now
if any(state.stop_word.process_streaming(mi) for mi in micro_inputs) and state.stop_word.is_active:
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() state.satellite.stop()
except Exception: _LOGGER.exception("Unexpected error handling audio") except Exception: _LOGGER.exception("Unexpected error handling audio")
except Exception: _LOGGER.exception("Unexpected error processing 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 ---")