Files
linux-voice-assistant/linux_voice_assistant/__main__.py
T
Omar Amin 3ea32895ee Wake word sensitivity (#207)
## Summary
- Introduces configurable wake word and stop word sensitivity
- Adds UI-controllable thresholds for wake word detection
- Refactors wake word loading and handling logic
- Improves audio preprocessing and runtime behavior

## Key Features
- Per-wake-word sensitivity configuration (Wake Word 1 & 2)
- Configurable stop word sensitivity
- Fine-grained numeric control (0.0 – 1.0, step 0.001)
- Sensitivity values exposed as Number Entities for Home Assistant
- Persistent storage of user-defined sensitivity values

## Wake Word Handling
- Support for model-specific default thresholds (`probability_cutoff`)
- Automatic fallback to defaults if no user value is set
- Separate thresholds for:
  - Wake Word 1
  - Wake Word 2
  - Stop Word
- Unified handling for MicroWakeWord and OpenWakeWord models

## UI / API Integration
- Adds new Home Assistant entities:
  - `WakeWord1SensitivityNumberEntity`
  - `WakeWord2SensitivityNumberEntity`
  - `StopWordSensitivityNumberEntity`
- Entities support:
  - Live updates
  - Persistence via preferences
  - Synchronization with runtime state
- Pushes updated values to Home Assistant on startup

## Refactoring
- Extracted wake word logic into `wake_word.py`
  - Model discovery
  - Model loading
  - Stop word handling
- Simplified `__main__.py` by delegating wake word responsibilities
- Improved separation between configuration and runtime state

## Additional Changes
- Improved debug logging across initialization and runtime

## Impact
- Enables fine-tuning of wake word detection accuracy
- Reduces false positives and missed triggers
- Improves usability via UI-based configuration
- Provides better flexibility across different environments and microphones
2026-04-21 02:04:11 +02:00

615 lines
26 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
from .mpv_player import MpvMediaPlayer
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,
# todo
)
parser.add_argument(
"--audio-output-device",
help="Name for the audio output device (see --list-output-devices)",
)
parser.add_argument(
"--list-output-devices",
action="store_true",
help="List audio output devices and exit",
)
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(
"--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(
"--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(
"--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(
"--preferences-file",
default=_REPO_DIR / "preferences.json",
help="Directory and file name for the file where the preferences are stored in JSON format",
)
parser.add_argument(
"--host",
help="Optional host IP address to bind to (default: Autodetected by network interface)", # 0.0.0.0 is IPv4, None is all interfaces
)
parser.add_argument(
"--network-interface",
help="Network interface the application will be listening on (default: will be automatically 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 listenening on (default: 6053)",
)
parser.add_argument(
"--enable-thinking-sound",
action="store_true",
help="Enable thinking finish sound, when the assistant is done thinking and needed more time to process",
)
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(
"--debug",
action="store_true",
help="Add this to enable debug logging",
)
parser.add_argument(
"--output-only",
action="store_true",
help="Enable output only mode",
)
args = parser.parse_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
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
# 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]
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
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_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)
# 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.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
wakeup_sound=args.wakeup_sound,
timer_finished_sound=args.timer_finished_sound,
processing_sound=args.processing_sound,
mute_sound=args.mute_sound,
unmute_sound=args.unmute_sound,
preferences=preferences,
preferences_path=preferences_path,
refractory_seconds=args.refractory_seconds,
output_only=args.output_only,
download_dir=args.download_dir,
volume=initial_volume,
mic_volume=preferences.mic_volume,
mic_auto_gain=preferences.mic_auto_gain,
mic_noise_suppression=preferences.mic_noise_suppression,
timer_max_ring_seconds=args.timer_max_ring_seconds,
)
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)
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 # connect 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)
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()
try:
async with server: # type: ignore
_LOGGER.info("Server started (host=%s, port=%s)", host_ip_address, args.port)
await server.serve_forever() # type: ignore
except KeyboardInterrupt:
pass
finally:
state.audio_queue.put_nowait(None)
process_audio_thread.join()
_LOGGER.debug("Server stopped")
# -----------------------------------------------------------------------------
def process_audio(state: ServerState, mic, block_size: int):
"""Process audio chunks from the microphone."""
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=1, blocksize=block_size) as mic_in:
while True:
audio_chunk_array = mic_in.record(block_size).reshape(-1)
# little-endian 16-bit signed
mic_vol_scalar = max(0.1, min(1.0, state.mic_volume / 100.0))
audio_chunk = (np.clip(audio_chunk_array * mic_vol_scalar, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
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:
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_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:
_LOGGER.exception("Unexpected error handling audio")
except Exception:
_LOGGER.exception("Unexpected error processing audio")
sys.exit(1)
# -----------------------------------------------------------------------------
def run():
asyncio.run(main())
if __name__ == "__main__":
run()