Files
linux-voice-assistant/linux_voice_assistant/__main__.py
T
imonlinux a4fa7b8631 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
2025-11-17 20:21:24 -08:00

386 lines
14 KiB
Python

#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
import sys
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional, Set, Union
import numpy as np
import soundcard as sc
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
from .config import Config, load_config_from_json
from .event_bus import EventBus, EventHandler, subscribe
from .led_controller import LedController
from .mqtt_controller import MqttController
from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType
from .mpv_player import MpvMediaPlayer
from .satellite import VoiceSatelliteProtocol
from .util import get_mac
from .zeroconf import HomeAssistantZeroconf
_LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent
_REPO_DIR = _MODULE_DIR.parent
# -----------------------------------------------------------------------------
class MicMuteHandler(EventHandler):
"""Event handler for mic mute switch."""
def __init__(self, state, mqtt_controller: Optional[MqttController]):
super().__init__(state)
self.mqtt_controller = mqtt_controller
@subscribe
def set_mic_mute(self, data: dict):
is_muted = data.get("state", False)
if self.state.mic_muted != is_muted:
self.state.mic_muted = is_muted
_LOGGER.debug("Mic muted = %s", is_muted)
if self.mqtt_controller:
self.mqtt_controller.publish_mute_state(is_muted)
if is_muted:
self.state.event_bus.publish("mic_muted")
else:
self.state.event_bus.publish("mic_unmuted")
# -----------------------------------------------------------------------------
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"-c",
"--config",
type=Path,
required=False,
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()
# --- Handle List Devices ---
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
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)
loop = asyncio.get_running_loop()
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 ---
preferences_path = _REPO_DIR / config.app.preferences_file
if preferences_path.exists():
with open(preferences_path, "r", encoding="utf-8") as preferences_file:
preferences_dict = json.load(preferences_file)
preferences = Preferences(**preferences_dict)
else:
preferences = Preferences()
preferences.num_leds = getattr(preferences, 'num_leds', config.led.num_leds)
# --- Load Wake Words ---
available_wake_words: Dict[str, AvailableWakeWord] = {}
if not config.wake_word.directories:
config.wake_word.directories = ["wakewords", "wakewords/openWakeWord"]
# Add download directory to search path
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"):
model_id = model_config_path.stem
if model_id == config.wake_word.stop_model:
continue
with open(model_config_path, "r", encoding="utf-8") as model_config_file:
model_config = json.load(model_config_file)
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(
id=model_id,
type=model_type,
wake_word=model_config["wake_word"],
trained_languages=model_config.get("trained_languages", []),
wake_word_path=wake_word_path,
)
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
# --- Load Active Wake Word Models ---
active_wake_words: Set[str] = set()
wake_models: Dict[str, Union[MicroWakeWord, OpenWakeWord]] = {}
if preferences.active_wake_words:
for wake_word_id in preferences.active_wake_words:
wake_word = available_wake_words.get(wake_word_id)
if wake_word is None: continue
_LOGGER.debug("Loading wake model: %s", wake_word_id)
wake_models[wake_word_id] = wake_word.load()
active_wake_words.add(wake_word_id)
if not wake_models:
wake_word_id = config.wake_word.model
wake_word = available_wake_words.get(wake_word_id)
if wake_word:
_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 ---
stop_model: Optional[MicroWakeWord] = None
for ww_dir_str in config.wake_word.directories:
wake_word_dir = _REPO_DIR / ww_dir_str
stop_config_path = wake_word_dir / f"{config.wake_word.stop_model}.json"
if not stop_config_path.exists(): continue
_LOGGER.debug("Loading stop model: %s", stop_config_path)
stop_model = MicroWakeWord.from_config(stop_config_path)
break
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 ---
music_player = MpvMediaPlayer(
loop=loop,
device=config.audio.output_device,
initial_volume=preferences.volume_level
)
tts_player = MpvMediaPlayer(
loop=loop,
device=config.audio.output_device,
initial_volume=preferences.volume_level
)
# --- Create Global Server State ---
state = ServerState(
name=config.app.name,
mac_address=get_mac(),
entities=[],
available_wake_words=available_wake_words,
wake_words=wake_models,
active_wake_words=active_wake_words,
stop_word=stop_model,
music_player=music_player,
tts_player=tts_player,
wakeup_sound=str(_REPO_DIR / config.app.wakeup_sound),
timer_finished_sound=str(_REPO_DIR / config.app.timer_finished_sound),
preferences=preferences,
preferences_path=preferences_path,
event_bus=event_bus,
loop=loop,
download_dir=download_dir, # <-- ADDED
refractory_seconds=config.wake_word.refractory_seconds,
)
# --- Initialize Controllers ---
led_controller = LedController(
state,
led_type=config.led.led_type,
interface=config.led.interface,
clock_pin=config.led.clock_pin,
data_pin=config.led.data_pin,
num_leds=state.preferences.num_leds,
)
mqtt_controller: Optional[MqttController] = None
if config.mqtt.enabled:
mqtt_controller = MqttController(
state=state,
host=config.mqtt.host,
port=config.mqtt.port,
username=config.mqtt.username,
password=config.mqtt.password
)
mqtt_controller.start()
mic_mute_handler = MicMuteHandler(state, mqtt_controller)
# --- Start Audio Processing Thread ---
process_audio_thread = threading.Thread(
target=process_audio,
args=(state, mic, config.audio.input_block_size),
daemon=True
)
process_audio_thread.start()
# --- Start ESPHome Server ---
server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state),
host=config.esphome.host,
port=config.esphome.port
)
discovery = HomeAssistantZeroconf(port=config.esphome.port, name=config.app.name)
await discovery.register_server()
# --- Run Forever ---
try:
async with server:
_LOGGER.info("Server started (host=%s, port=%s)", config.esphome.host, config.esphome.port)
await server.serve_forever()
except KeyboardInterrupt:
pass
finally:
if mqtt_controller:
mqtt_controller.stop()
state.mic_muted = True
process_audio_thread.join()
_LOGGER.debug("Server stopped")
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]] = []
micro_features: Optional[MicroWakeWordFeatures] = None
micro_inputs: List[np.ndarray] = []
oww_features: Optional[OpenWakeWordFeatures] = None
oww_inputs: List[np.ndarray] = []
has_oww = False
last_active: Optional[float] = None
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:
if state.mic_muted:
time.sleep(0.1)
continue
audio_chunk_array = mic_in.record(block_size).reshape(-1)
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):
state.wake_words_changed = False
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)
if micro_features is None:
micro_features = MicroWakeWordFeatures()
if has_oww and (oww_features is None):
oww_features = OpenWakeWordFeatures.from_builtin()
try:
state.satellite.handle_audio(audio_chunk)
assert micro_features is not None
micro_inputs.clear(); micro_inputs.extend(micro_features.process_streaming(audio_chunk))
if has_oww:
assert oww_features is not None
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__":
print("--- __main__ block executing ---")
asyncio.run(main())