* Restore stop word sensitivity from preferences on startup The Home Assistant number entity persists preferences.stop_word_sensitivity whenever it changes, but nothing ever read it back. main() did not pass stop_word_threshold when constructing ServerState, so it always took the dataclass default of 0.5, and lowering the sensitivity in Home Assistant silently reverted at the next restart. volume and mic_volume are restored at startup, and the two wake word sensitivities are restored when wake models load. Only the stop word was missed, so the block that syncs all three sensitivity entities back to Home Assistant was pushing a default it had never loaded. Restored alongside volume and clamped the same way. The resolution lives in a small helper so it can be covered by tests. * Move the threshold helper to models.py so tests do not import __main__ CI has no PulseAudio daemon. Importing linux_voice_assistant.__main__ pulls in `import soundcard as sc`, and soundcard/pulseaudio.py instantiates _PulseAudio() at module scope, which asserts on a live connection. So the four new tests failed on the runner while passing locally, where a daemon happens to be running. That is also why no other test in the suite imports __main__, and why the existing tests in test_main.py re-implement the logic they check rather than calling it. initial_stop_word_threshold() now lives in models.py, which owns both fields it resolves between: Preferences.stop_word_sensitivity and ServerState.stop_word_threshold. It imports cleanly, test_models.py already imports it, and the reference to the ServerState default becomes local. Behaviour is unchanged. Verified by reproducing the CI environment locally with PULSE_SERVER pointed at a nonexistent socket: 4 failed before, 298 pass after. --------- Co-authored-by: ryan felder <7475484+ryanfelder@users.noreply.github.com> Co-authored-by: Omar Amin <65174210+omaramin-2000@users.noreply.github.com>
851 lines
35 KiB
Python
851 lines
35 KiB
Python
#!/usr/bin/env python3
|
||
import argparse
|
||
import asyncio
|
||
import errno
|
||
import json
|
||
import logging
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from queue import Queue
|
||
from typing import List, Optional, Union
|
||
|
||
import numpy as np
|
||
import soundcard as sc
|
||
from aioesphomeapi.api_pb2 import NumberStateResponse # type: ignore # pylint: disable=no-name-in-module
|
||
from getmac import get_mac_address # type: ignore
|
||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||
|
||
from .models import Preferences, ServerState, WakeWordType, initial_stop_word_threshold
|
||
from .mpv_player import MpvMediaPlayer
|
||
from .peripheral_api import LVAEvent, PeripheralAPIServer
|
||
from .satellite import VoiceSatelliteProtocol
|
||
from .util import (
|
||
get_default_interface,
|
||
get_default_ipv4,
|
||
get_esphome_version,
|
||
get_version,
|
||
)
|
||
from .wake_word import find_available_wake_words, load_stop_model, load_wake_models
|
||
from .webrtc import WebRTCProcessor
|
||
from .zeroconf import HomeAssistantZeroconf
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
_MODULE_DIR = Path(__file__).parent
|
||
_REPO_DIR = _MODULE_DIR.parent
|
||
_WAKEWORDS_DIR = _REPO_DIR / "wakewords"
|
||
_SOUNDS_DIR = _REPO_DIR / "sounds"
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
async def main() -> None:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument(
|
||
"--name",
|
||
help="Real name for the device",
|
||
)
|
||
parser.add_argument(
|
||
"--audio-input-device",
|
||
help="Name for the audio input device (see --list-input-devices)",
|
||
)
|
||
parser.add_argument(
|
||
"--list-input-devices",
|
||
action="store_true",
|
||
help="List audio input devices and exit",
|
||
)
|
||
parser.add_argument(
|
||
"--audio-input-block-size",
|
||
type=int,
|
||
default=1024,
|
||
)
|
||
parser.add_argument(
|
||
"--audio-output-device",
|
||
help="Name for the audio output device (see --list-output-devices)",
|
||
)
|
||
parser.add_argument(
|
||
"--music-output-device",
|
||
help="mpv name for the music/media output device (defaults to --audio-output-device)",
|
||
)
|
||
parser.add_argument(
|
||
"--list-output-devices",
|
||
action="store_true",
|
||
help="List audio output devices and exit",
|
||
)
|
||
parser.add_argument("--mic-volume", type=int, default=100, choices=list(range(1, 101)), help="Microphone volume level (1 to 100)")
|
||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||
parser.add_argument("--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4))
|
||
parser.add_argument(
|
||
"--audio-input-channels",
|
||
type=int,
|
||
default=1,
|
||
choices=(1, 2),
|
||
help="Number of mic channels to capture and stream (1=mono, 2=dual-channel voice)",
|
||
)
|
||
parser.add_argument(
|
||
"--wake-word-dir",
|
||
default=[_WAKEWORDS_DIR],
|
||
action="append",
|
||
help="Directory with wake word models (.tflite) and configuration (.json)",
|
||
)
|
||
parser.add_argument(
|
||
"--wake-model",
|
||
default="okay_nabu",
|
||
help="File name of the first active wake model",
|
||
)
|
||
parser.add_argument(
|
||
"--stop-model",
|
||
default="stop",
|
||
help="File name of the stop model",
|
||
)
|
||
parser.add_argument(
|
||
"--download-dir",
|
||
default=_REPO_DIR / "local",
|
||
help="Directory to download custom wake word models to",
|
||
)
|
||
parser.add_argument(
|
||
"--refractory-seconds",
|
||
default=2.0,
|
||
type=float,
|
||
help="Seconds before wake word can be activated again",
|
||
)
|
||
parser.add_argument(
|
||
"--continue-conversation-delay",
|
||
type=float,
|
||
default=0.5,
|
||
help="Seconds to wait after TTS finishes before opening the mic for continued conversation (default: 0.5)",
|
||
)
|
||
parser.add_argument(
|
||
"--wakeup-sound",
|
||
default=str(_SOUNDS_DIR / "wake_word_triggered.flac"),
|
||
help="Directory and file name for wake sound (when you say the wake word)",
|
||
)
|
||
parser.add_argument(
|
||
"--start-listening-sound",
|
||
default=str(_SOUNDS_DIR / "start_listening_button.flac"),
|
||
help="Directory and file name and sound for start listening button (when you press button to talk)",
|
||
)
|
||
parser.add_argument(
|
||
"--timer-finished-sound",
|
||
default=str(_SOUNDS_DIR / "timer_finished.flac"),
|
||
help="Directory and file name for timer finished sound",
|
||
)
|
||
parser.add_argument(
|
||
"--processing-sound",
|
||
default=str(_SOUNDS_DIR / "processing.wav"),
|
||
help="Short sound to play while assistant is processing (thinking)",
|
||
)
|
||
parser.add_argument(
|
||
"--mute-sound",
|
||
default=str(_SOUNDS_DIR / "mute_switch_on.flac"),
|
||
help="Sound to play when muting the assistant",
|
||
)
|
||
parser.add_argument(
|
||
"--unmute-sound",
|
||
default=str(_SOUNDS_DIR / "mute_switch_off.flac"),
|
||
help="Sound to play when unmuting the assistant",
|
||
)
|
||
parser.add_argument(
|
||
"--button-double-press-sound",
|
||
default=str(_SOUNDS_DIR / "button_double_press.flac"),
|
||
help="Sound to play for button double press",
|
||
)
|
||
parser.add_argument(
|
||
"--button-triple-press-sound",
|
||
default=str(_SOUNDS_DIR / "button_triple_press.flac"),
|
||
help="Sound to play for button triple press",
|
||
)
|
||
parser.add_argument(
|
||
"--button-long-press-sound",
|
||
default=str(_SOUNDS_DIR / "button_long_press.flac"),
|
||
help="Sound to play for button long press",
|
||
)
|
||
parser.add_argument(
|
||
"--preferences-file",
|
||
default=_REPO_DIR / "preferences.json",
|
||
help="Directory and file name for the preferences JSON file",
|
||
)
|
||
parser.add_argument(
|
||
"--host",
|
||
help="Optional host IP address to bind to (default: auto-detected by network interface)",
|
||
)
|
||
parser.add_argument(
|
||
"--network-interface",
|
||
help="Network interface the application listens on (default: auto-detected by gateway)",
|
||
)
|
||
# Note that default port is also set in docker-entrypoint.sh
|
||
parser.add_argument(
|
||
"--port",
|
||
type=int,
|
||
default=6053,
|
||
help="Port the application is listening on (default: 6053)",
|
||
)
|
||
parser.add_argument(
|
||
"--enable-thinking-sound",
|
||
action="store_true",
|
||
help="Enable thinking sound on startup",
|
||
)
|
||
# ------------------------------------------------------------------
|
||
# Peripheral API (LEDs, buttons, HAT boards)
|
||
# ------------------------------------------------------------------
|
||
parser.add_argument(
|
||
"--peripheral-host",
|
||
default="0.0.0.0",
|
||
help="Bind address for the peripheral WebSocket API (default: 0.0.0.0)",
|
||
)
|
||
parser.add_argument(
|
||
"--peripheral-port",
|
||
type=int,
|
||
default=6055,
|
||
help="Port for the peripheral WebSocket API (default: 6055)",
|
||
)
|
||
parser.add_argument(
|
||
"--peripheral-volume-step",
|
||
type=float,
|
||
default=PeripheralAPIServer.DEFAULT_VOLUME_STEP,
|
||
metavar="STEP",
|
||
help="Volume change per button press, 0.0–1.0 (default: %(default)s)",
|
||
)
|
||
parser.add_argument(
|
||
"--disable-peripheral-api",
|
||
action="store_true",
|
||
help="Disable the peripheral WebSocket API entirely",
|
||
)
|
||
parser.add_argument(
|
||
"--peripheral-startup-wait",
|
||
type=float,
|
||
default=2.0,
|
||
metavar="SECONDS",
|
||
help="Seconds to wait for peripherals to connect and register their entities before HA enumerates the ESPHome API (default: %(default)s; set 0 to skip).",
|
||
)
|
||
# ------------------------------------------------------------------
|
||
parser.add_argument(
|
||
"--timer-max-ring-seconds",
|
||
type=float,
|
||
default=900.0, # 15 minutes
|
||
help="Seconds before a ringing timer auto-stops (default: 900)",
|
||
)
|
||
parser.add_argument(
|
||
"--listen-during-wake-sound",
|
||
action="store_true",
|
||
help="Start listening immediately after wake word detection, without waiting for the wake sound to finish",
|
||
)
|
||
parser.add_argument(
|
||
"--debug",
|
||
action="store_true",
|
||
help="Add this to enable debug logging",
|
||
)
|
||
parser.add_argument(
|
||
"--colored-debug",
|
||
action="store_true",
|
||
help="Add this to enable colored debug logging",
|
||
)
|
||
parser.add_argument(
|
||
"--output-only",
|
||
action="store_true",
|
||
help="Enable output only mode",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.colored_debug:
|
||
args.debug = True
|
||
_setup_logging(args)
|
||
elif args.debug:
|
||
logging.basicConfig(level=logging.DEBUG)
|
||
else:
|
||
logging.basicConfig(level=logging.INFO)
|
||
|
||
_LOGGER.debug(args)
|
||
if args.list_input_devices:
|
||
print("Audio Input devices:")
|
||
print("=" * 13)
|
||
for idx, mic in enumerate(sc.all_microphones()):
|
||
print(f"[{idx}]", mic.name)
|
||
return
|
||
|
||
if args.list_output_devices:
|
||
from mpv import MPV
|
||
|
||
player = MPV()
|
||
print("Audio output devices:")
|
||
print("=" * 14)
|
||
|
||
for speaker in player.audio_device_list: # type: ignore
|
||
print(speaker["name"] + ":", speaker["description"])
|
||
return
|
||
|
||
# Resolve network interface for mac-address detection
|
||
if not args.network_interface:
|
||
print("No network interface specified, try to detect default interface")
|
||
network_interface = get_default_interface()
|
||
print(f"Default interface detected: {network_interface}")
|
||
else:
|
||
print("Network interface specified")
|
||
network_interface = args.network_interface
|
||
print(f"Using network interface: {network_interface}")
|
||
|
||
# Resolve ip_address where the application will be listening
|
||
if not args.host:
|
||
print("No host (ip-address) specified, try to detect IP-Address")
|
||
host_ip_address = get_default_ipv4(network_interface)
|
||
print(f"IP-Address detected: {host_ip_address}")
|
||
else:
|
||
print("Host specified")
|
||
print(f"Using host: {args.host}")
|
||
host_ip_address = args.host
|
||
|
||
# Resolve mac
|
||
if not (mac_address := get_mac_address(interface=network_interface)):
|
||
print("No Mac address was found, app stopped.")
|
||
sys.exit(1)
|
||
mac_address_clean = mac_address.replace(":", "").lower()
|
||
|
||
# Resolve name
|
||
if not args.name:
|
||
print("No friendly name specified, try to autogenerate name")
|
||
friendly_name = f"LVA - {mac_address_clean}"
|
||
print(f"Friendly name autogenerated: {friendly_name}")
|
||
else:
|
||
print("Friendly name specified")
|
||
print(f"Using friendly name: {args.name}")
|
||
friendly_name = args.name
|
||
|
||
device_name = f"lva-{mac_address_clean}"
|
||
|
||
print(f"Device name: {device_name}")
|
||
|
||
# Resolve version
|
||
version = get_version()
|
||
print(f"Version: {version}")
|
||
|
||
# Resolve esphome version
|
||
esphome_version = get_esphome_version()
|
||
print(f"ESPHome api version: {esphome_version}")
|
||
|
||
# Resolve download dir
|
||
args.download_dir = Path(args.download_dir)
|
||
args.download_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Resolve microphone
|
||
if args.audio_input_device is not None:
|
||
try:
|
||
args.audio_input_device = int(args.audio_input_device)
|
||
except ValueError:
|
||
pass
|
||
|
||
mic = sc.get_microphone(args.audio_input_device)
|
||
else:
|
||
mic = sc.default_microphone()
|
||
|
||
# Load available wake words
|
||
wake_word_dirs = [Path(ww_dir) for ww_dir in args.wake_word_dir]
|
||
|
||
# If the operator explicitly pointed --wake-word-dir (or the WAKE_WORD_DIR
|
||
# env var) at the openWakeWord subdirectory, prefer resolving --wake-model
|
||
# to an openWakeWord model of the same name instead of a same-named
|
||
# microWakeWord one. Checked before the automatic dirs below are appended,
|
||
# since those always include the openWakeWord path and would otherwise
|
||
# make every configuration look like an openWakeWord preference.
|
||
preferred_wake_word_type = WakeWordType.OPEN_WAKE_WORD if any("openwakeword" in str(ww_dir).lower() for ww_dir in wake_word_dirs) else None
|
||
|
||
# openWakeWord models ship in their own subdirectory under the default
|
||
# wakewords dir. find_available_wake_words() only globs the top level of
|
||
# each directory it's given, so this must be added explicitly or the OWW
|
||
# models never get discovered (and never show up in the HA dropdown).
|
||
# Appended after the user-specified dirs so OWW entries are inserted
|
||
# (and therefore displayed) after the microWakeWord ones.
|
||
oww_dir = _WAKEWORDS_DIR / "openWakeWord"
|
||
if oww_dir not in wake_word_dirs:
|
||
wake_word_dirs.append(oww_dir)
|
||
|
||
wake_word_dirs.append(args.download_dir / "external_wake_words")
|
||
available_wake_words = find_available_wake_words(wake_word_dirs, args.stop_model)
|
||
|
||
# Load preferences
|
||
preferences_path = Path(args.preferences_file)
|
||
if preferences_path.exists():
|
||
_LOGGER.debug("Loading preferences: %s", preferences_path)
|
||
with open(preferences_path, "r", encoding="utf-8") as preferences_file:
|
||
preferences_dict = json.load(preferences_file)
|
||
preferences = Preferences(**preferences_dict)
|
||
else:
|
||
preferences = Preferences()
|
||
|
||
# Load volume from preferences on startup, and ensure it's between 0.0 and 1.0
|
||
initial_volume = preferences.volume if preferences.volume is not None else 1.0
|
||
initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||
preferences.volume = initial_volume
|
||
|
||
# Load stop word sensitivity from preferences on startup, and ensure it's between 0.0 and 1.0
|
||
initial_threshold = initial_stop_word_threshold(preferences.stop_word_sensitivity)
|
||
preferences.stop_word_sensitivity = initial_threshold
|
||
|
||
if args.enable_thinking_sound:
|
||
preferences.thinking_sound = 1
|
||
|
||
if args.mic_auto_gain or args.mic_noise_suppression:
|
||
try:
|
||
import webrtc_noise_gain # type: ignore[import-untyped] # noqa: F401
|
||
except ImportError:
|
||
_LOGGER.exception("Extras for webrtc are not installed")
|
||
sys.exit(1)
|
||
|
||
if args.mic_volume > 0.0:
|
||
preferences.mic_volume = args.mic_volume
|
||
if args.mic_auto_gain > 0:
|
||
preferences.mic_auto_gain = args.mic_auto_gain
|
||
|
||
if args.mic_noise_suppression > 0:
|
||
preferences.mic_noise_suppression = args.mic_noise_suppression
|
||
|
||
# Load wake/stop models
|
||
wake_models, active_wake_words, fallback_used = load_wake_models(
|
||
available_wake_words,
|
||
[word for word in preferences.active_wake_words if word is not None],
|
||
args.wake_model,
|
||
preferred_type=preferred_wake_word_type,
|
||
)
|
||
|
||
# TODO: allow openWakeWord for "stop"
|
||
stop_model = load_stop_model(wake_word_dirs, args.stop_model)
|
||
assert stop_model is not None
|
||
|
||
state = ServerState(
|
||
name=device_name,
|
||
friendly_name=friendly_name,
|
||
network_interface=network_interface,
|
||
mac_address=mac_address,
|
||
ip_address=host_ip_address,
|
||
version=version,
|
||
esphome_version=esphome_version,
|
||
audio_queue=Queue(),
|
||
entities=[],
|
||
available_wake_words=available_wake_words,
|
||
wake_words=wake_models,
|
||
active_wake_words=active_wake_words,
|
||
stop_word=stop_model,
|
||
music_player=MpvMediaPlayer(device=args.music_output_device or args.audio_output_device),
|
||
tts_player=MpvMediaPlayer(device=args.audio_output_device),
|
||
wakeup_sound=args.wakeup_sound,
|
||
start_listening_sound=args.start_listening_sound,
|
||
timer_finished_sound=args.timer_finished_sound,
|
||
processing_sound=args.processing_sound,
|
||
mute_sound=args.mute_sound,
|
||
unmute_sound=args.unmute_sound,
|
||
button_double_press_sound=args.button_double_press_sound,
|
||
button_triple_press_sound=args.button_triple_press_sound,
|
||
button_long_press_sound=args.button_long_press_sound,
|
||
preferences=preferences,
|
||
preferences_path=preferences_path,
|
||
refractory_seconds=args.refractory_seconds,
|
||
continue_conversation_delay=args.continue_conversation_delay,
|
||
output_only=args.output_only,
|
||
download_dir=args.download_dir,
|
||
volume=initial_volume,
|
||
stop_word_threshold=initial_threshold,
|
||
mic_volume=preferences.mic_volume,
|
||
mic_auto_gain=preferences.mic_auto_gain,
|
||
mic_noise_suppression=preferences.mic_noise_suppression,
|
||
audio_input_channels=args.audio_input_channels,
|
||
timer_max_ring_seconds=args.timer_max_ring_seconds,
|
||
listen_during_wake_sound=args.listen_during_wake_sound,
|
||
)
|
||
|
||
if fallback_used:
|
||
# Fallback to the default model was used, save as active wake words
|
||
_LOGGER.debug("Fallback was used, save default wake words in Preferences.")
|
||
state.preferences.active_wake_words = list(active_wake_words)
|
||
state.active_wake_words = active_wake_words
|
||
state.wake_words = wake_models
|
||
state.save_preferences()
|
||
state.wake_words_changed = True
|
||
|
||
if args.enable_thinking_sound or args.mic_auto_gain or args.mic_noise_suppression:
|
||
state.save_preferences()
|
||
|
||
initial_volume_percent = int(round(initial_volume * 100))
|
||
state.music_player.set_volume(initial_volume_percent)
|
||
state.tts_player.set_volume(initial_volume_percent)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Peripheral API (optional – LEDs, buttons, HAT boards)
|
||
# ------------------------------------------------------------------
|
||
peripheral_api: Optional[PeripheralAPIServer] = None
|
||
if not args.disable_peripheral_api:
|
||
peripheral_api = PeripheralAPIServer(
|
||
host=args.peripheral_host,
|
||
port=args.peripheral_port,
|
||
volume_step=args.peripheral_volume_step,
|
||
)
|
||
peripheral_api.set_state(state)
|
||
state.peripheral_api = peripheral_api
|
||
|
||
# ------------------------------------------------------------------
|
||
# ESPHome TCP server (with retry on EADDRINUSE)
|
||
# ------------------------------------------------------------------
|
||
loop = asyncio.get_running_loop()
|
||
max_attempts = 15
|
||
attempt = 1
|
||
server = None
|
||
|
||
# Validate VoiceSatelliteProtocol initialization BEFORE starting server
|
||
# This catches errors like missing imports or broken initialization immediately
|
||
# instead of failing silently only when first client connects
|
||
_LOGGER.debug("Validating VoiceSatelliteProtocol initialization...")
|
||
try:
|
||
# Create test instance to run complete __init__ code path
|
||
test_protocol = VoiceSatelliteProtocol(state)
|
||
# Cleanup state reference
|
||
test_protocol.state.satellite = None
|
||
del test_protocol
|
||
_LOGGER.debug("✅ VoiceSatelliteProtocol validation successful")
|
||
except Exception:
|
||
_LOGGER.critical("❌ FATAL ERROR in VoiceSatelliteProtocol initialization!", exc_info=True)
|
||
_LOGGER.critical("Program will exit immediately - fix the error above first!")
|
||
sys.exit(1)
|
||
|
||
while attempt <= max_attempts:
|
||
try:
|
||
server = await loop.create_server(
|
||
lambda: VoiceSatelliteProtocol(state),
|
||
host=host_ip_address,
|
||
port=args.port,
|
||
)
|
||
break # connection successful, exit the loop
|
||
except OSError as err:
|
||
message = err.strerror or str(err)
|
||
if err.errno == errno.EADDRINUSE:
|
||
message = "address already in use"
|
||
if attempt < max_attempts:
|
||
_LOGGER.warning(
|
||
"Attempt %d/%d failed to bind on address (%s, %s): %s. Retrying in 1 second...",
|
||
attempt,
|
||
max_attempts,
|
||
host_ip_address,
|
||
args.port,
|
||
message,
|
||
)
|
||
await asyncio.sleep(1)
|
||
attempt += 1
|
||
else:
|
||
_LOGGER.exception(
|
||
"All %d attempts failed to bind on address (%s, %s): %s",
|
||
max_attempts,
|
||
host_ip_address,
|
||
args.port,
|
||
message,
|
||
)
|
||
sys.exit(1)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Audio processing thread
|
||
# ------------------------------------------------------------------
|
||
process_audio_thread = threading.Thread(
|
||
target=process_audio,
|
||
args=(state, mic, args.audio_input_block_size),
|
||
daemon=True,
|
||
)
|
||
process_audio_thread.start()
|
||
|
||
# Auto discovery (zeroconf, mDNS)
|
||
discovery = HomeAssistantZeroconf(
|
||
port=args.port,
|
||
name=state.name,
|
||
mac_address=state.mac_address,
|
||
host_ip_address=host_ip_address,
|
||
)
|
||
await discovery.register_server()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Start peripheral API and signal "getting started" to peripherals
|
||
# ------------------------------------------------------------------
|
||
if peripheral_api is not None:
|
||
await peripheral_api.start()
|
||
await peripheral_api.emit_event(LVAEvent.ZEROCONF, {"status": "getting_started"})
|
||
|
||
# Give peripherals a window to connect and register their Light
|
||
# entities before HA enumerates over the ESPHome native API. The
|
||
# ESPHome server is bound but not yet serving (serve_forever runs
|
||
# below), so any HA connection sits queued in the kernel for the
|
||
# duration of this wait. Peripherals that register later still
|
||
# work, but the new entities only show up in HA after the
|
||
# integration reconnects.
|
||
if args.peripheral_startup_wait > 0:
|
||
_LOGGER.info(
|
||
"Waiting %.1fs for peripherals to register entities…",
|
||
args.peripheral_startup_wait,
|
||
)
|
||
await asyncio.sleep(args.peripheral_startup_wait)
|
||
|
||
try:
|
||
async with server: # type: ignore[union-attr]
|
||
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
|
||
await server.serve_forever() # type: ignore[union-attr]
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
state.audio_queue.put_nowait(None)
|
||
process_audio_thread.join()
|
||
if peripheral_api is not None:
|
||
await peripheral_api.stop()
|
||
|
||
_LOGGER.debug("Server stopped")
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
def _setup_logging(args: argparse.Namespace) -> None:
|
||
COLORS = {
|
||
logging.DEBUG: "\033[36m",
|
||
logging.INFO: "\033[32m",
|
||
logging.WARNING: "\033[33m",
|
||
logging.ERROR: "\033[31m",
|
||
logging.CRITICAL: "\033[35m",
|
||
}
|
||
RESET = "\033[0m"
|
||
|
||
original_format = logging.Formatter.format
|
||
|
||
def colored_format(self, record: logging.LogRecord) -> str:
|
||
color = COLORS.get(record.levelno, RESET)
|
||
return f"{color}{original_format(self, record)}{RESET}"
|
||
|
||
logging.Formatter.format = colored_format # type: ignore
|
||
|
||
handler = logging.StreamHandler()
|
||
handler.setFormatter(
|
||
logging.Formatter(
|
||
fmt="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
datefmt="%H:%M:%S",
|
||
)
|
||
)
|
||
logging.basicConfig(
|
||
level=logging.DEBUG if args.debug else logging.INFO,
|
||
handlers=[handler],
|
||
)
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
def process_audio(state: ServerState, mic, block_size: int):
|
||
"""Process audio chunks from the microphone."""
|
||
n_channels = state.audio_input_channels
|
||
|
||
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
|
||
webrtc: Optional[WebRTCProcessor] = None
|
||
|
||
try:
|
||
_LOGGER.debug("Opening audio input device: %s", mic.name)
|
||
with mic.recorder(samplerate=16000, channels=n_channels, blocksize=block_size) as mic_in:
|
||
while True:
|
||
# Shape: (block_size, n_channels) for stereo, (block_size, 1) for mono.
|
||
raw = mic_in.record(block_size) # float32, range [-1, 1]
|
||
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
|
||
|
||
# Build per-channel byte arrays. Channel 0 is the primary
|
||
# microphone; channel 1 (when present) is the reference/speaker
|
||
# feed used for server-side AEC.
|
||
channel_chunks: list[bytes] = []
|
||
for ch in range(n_channels):
|
||
col = raw[:, ch] if n_channels > 1 else raw.reshape(-1)
|
||
chunk = (np.clip(col * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
||
channel_chunks.append(chunk)
|
||
|
||
# Primary channel drives WebRTC and wake-word detection.
|
||
audio_chunk = channel_chunks[0]
|
||
agc = state.preferences.mic_auto_gain or 0
|
||
ns = state.preferences.mic_noise_suppression or 0
|
||
|
||
if agc > 0 or ns > 0:
|
||
if webrtc is None:
|
||
webrtc = WebRTCProcessor(agc_level=agc, ns_level=ns)
|
||
else:
|
||
webrtc.update_settings(agc, ns)
|
||
audio_chunk = webrtc.process(audio_chunk)
|
||
if not audio_chunk:
|
||
continue
|
||
|
||
if state.satellite is None or not hasattr(state.satellite, "_is_streaming_audio"):
|
||
continue
|
||
|
||
# WAKE WORD
|
||
if (not wake_words) or (state.wake_words_changed and state.wake_words):
|
||
# Update list of wake word models to process
|
||
state.wake_words_changed = False
|
||
wake_words = [ww for ww in state.wake_words.values() if ww.id in state.active_wake_words]
|
||
|
||
# TODO: Load default stop word value from json into state and preferences missing.
|
||
|
||
has_oww = False
|
||
for idx, wake_word in enumerate(wake_words):
|
||
|
||
# Load default threshold from model json
|
||
wake_word_id = wake_word.id if hasattr(wake_word, "id") else next(iter(state.wake_words.keys()))
|
||
available_word = state.available_wake_words.get(wake_word_id)
|
||
# _LOGGER.debug("word= %s", state.available_wake_words.get(wake_word_id))
|
||
default_threshold = available_word.probability_cutoff if available_word else 0.7
|
||
_LOGGER.debug("Using default threshold %.3f for wake word '%s' from model config", default_threshold, wake_word_id)
|
||
# Check preferences override
|
||
if idx == 0:
|
||
old_val = state.wake_word_1_threshold
|
||
if state.preferences.wake_word_1_sensitivity is not None:
|
||
state.wake_word_1_threshold = state.preferences.wake_word_1_sensitivity
|
||
else:
|
||
state.wake_word_1_threshold = default_threshold
|
||
_LOGGER.debug("Wake Word 1 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_1_threshold, old_val, state.preferences.wake_word_1_sensitivity)
|
||
elif idx == 1:
|
||
old_val = state.wake_word_2_threshold
|
||
if state.preferences.wake_word_2_sensitivity is not None:
|
||
state.wake_word_2_threshold = state.preferences.wake_word_2_sensitivity
|
||
else:
|
||
state.wake_word_2_threshold = default_threshold
|
||
_LOGGER.debug("Wake Word 2 threshold set to %.3f (was %.3f, preferences: %s)", state.wake_word_2_threshold, old_val, state.preferences.wake_word_2_sensitivity)
|
||
|
||
if isinstance(wake_word, OpenWakeWord):
|
||
has_oww = True
|
||
|
||
# Sync entity states after threshold values were updated
|
||
if state.satellite is not None:
|
||
_LOGGER.debug("Updating WebUI entities with new threshold values")
|
||
|
||
# Wake Word 1
|
||
if state.satellite.state.sensitivity_1_number_entity is not None:
|
||
_LOGGER.debug(" → Syncing Wake Word 1 entity to value %.3f", state.wake_word_1_threshold)
|
||
state.satellite.state.sensitivity_1_number_entity.sync_with_state()
|
||
_LOGGER.debug(" ✅ Wake Word 1 entity now has value %.3f", state.satellite.state.sensitivity_1_number_entity.value)
|
||
|
||
# Wake Word 2
|
||
if state.satellite.state.sensitivity_2_number_entity is not None:
|
||
_LOGGER.debug(" → Syncing Wake Word 2 entity to value %.3f", state.wake_word_2_threshold)
|
||
state.satellite.state.sensitivity_2_number_entity.sync_with_state()
|
||
_LOGGER.debug(" ✅ Wake Word 2 entity now has value %.3f", state.satellite.state.sensitivity_2_number_entity.value)
|
||
|
||
# Stop Word
|
||
if state.satellite.state.stop_sensitivity_number_entity is not None:
|
||
_LOGGER.debug(" → Syncing Stop Word entity to value %.3f", state.stop_word_threshold)
|
||
state.satellite.state.stop_sensitivity_number_entity.sync_with_state()
|
||
_LOGGER.debug(" ✅ Stop Word entity now has value %.3f", state.satellite.state.stop_sensitivity_number_entity.value)
|
||
|
||
_LOGGER.debug("All sensitivity entities synced successfully")
|
||
|
||
# Force push new state to connected Home Assistant instance
|
||
if state.satellite is not None:
|
||
try:
|
||
_LOGGER.debug("Pushing updated state values to Home Assistant")
|
||
for entity in [
|
||
state.satellite.state.sensitivity_1_number_entity,
|
||
state.satellite.state.sensitivity_2_number_entity,
|
||
state.satellite.state.stop_sensitivity_number_entity,
|
||
]:
|
||
if entity is not None:
|
||
state.satellite.send_messages([NumberStateResponse(key=entity.key, state=entity.value)]) # type: ignore[attr-defined]
|
||
_LOGGER.debug(" → Pushed value %.3f for entity %d", entity.value, entity.key)
|
||
except Exception as e:
|
||
_LOGGER.debug("Could not push state (no client connected yet): %s", e)
|
||
|
||
# TODO: Save settings: At this moment settings are only saved when changed in the UI. Means that the default value can change while updating since its not saved in preferences.
|
||
|
||
if micro_features is None:
|
||
micro_features = MicroWakeWordFeatures()
|
||
|
||
if has_oww and (oww_features is None):
|
||
oww_features = OpenWakeWordFeatures.from_builtin()
|
||
|
||
try:
|
||
# Both channels travel in one message: data=ch0 (enhanced), data2=ch1 (raw reference)
|
||
audio_chunk_2 = channel_chunks[1] if n_channels >= 2 else None
|
||
state.satellite.handle_audio(audio_chunk, audio_chunk_2)
|
||
|
||
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_index, wake_word in enumerate(wake_words):
|
||
activated = False
|
||
|
||
# Set dynamic threshold depending on wake word index
|
||
if wake_word_index == 0:
|
||
threshold = state.wake_word_1_threshold
|
||
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_1_threshold)
|
||
elif wake_word_index == 1:
|
||
threshold = state.wake_word_2_threshold
|
||
# _LOGGER.debug("Set wake word %d probability cutoff to %.3f", wake_word_index+1, state.wake_word_2_threshold)
|
||
else:
|
||
threshold = 0.7
|
||
# _LOGGER.debug("Set wake word %d probability cutoff to fallback value 0.7", wake_word_index+1)
|
||
|
||
if isinstance(wake_word, MicroWakeWord):
|
||
# No debugging when no detection
|
||
wake_word.debug_probabilities = False
|
||
|
||
# set microWakeWord cutoff
|
||
wake_word.probability_cutoff = threshold
|
||
|
||
for micro_input in micro_inputs:
|
||
if wake_word.process_streaming(micro_input):
|
||
wake_word.debug_probabilities = True
|
||
activated = True
|
||
elif isinstance(wake_word, OpenWakeWord):
|
||
for oww_input in oww_inputs:
|
||
for prob in wake_word.process_streaming(oww_input):
|
||
if prob > threshold:
|
||
_LOGGER.debug("Wake word '%s' activated (probability %.3f exceeded threshold %.3f)", wake_word.wake_word, prob, threshold) # type: ignore[attr-defined]
|
||
activated = True
|
||
|
||
if activated and not state.muted:
|
||
# Check refractory
|
||
now = time.monotonic()
|
||
if (last_active is None) or ((now - last_active) > state.refractory_seconds):
|
||
state.satellite.wakeup(wake_word)
|
||
last_active = now
|
||
|
||
# Always process to keep state correct
|
||
stopped = False
|
||
|
||
# No debugging when no detection
|
||
state.stop_word.debug_probabilities = False
|
||
|
||
# Apply stop word sensitivity threshold
|
||
state.stop_word.probability_cutoff = state.stop_word_threshold
|
||
# _LOGGER.debug("Set stop word probability cutoff to %.3f", state.stop_word_threshold)
|
||
for micro_input in micro_inputs:
|
||
if state.stop_word.process_streaming(micro_input):
|
||
state.stop_word.debug_probabilities = True
|
||
stopped = True
|
||
|
||
if stopped and (state.stop_word.id in state.active_wake_words) and not state.muted:
|
||
_LOGGER.debug("Stop word detected")
|
||
state.satellite.stop()
|
||
except Exception: # pylint: disable=broad-except
|
||
_LOGGER.exception("Unexpected error handling audio")
|
||
except Exception: # pylint: disable=broad-except
|
||
_LOGGER.exception("Unexpected error processing audio")
|
||
sys.exit(1)
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
def run():
|
||
asyncio.run(main())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|