diff --git a/README.md b/README.md
index 3948ea1..bee2784 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Want to run the satellite on a Linux desktop using a simple tray client? See [th
- This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models.
- Refactor: Sync with Upstream architectural changes, includes the 7 commits since the release of v1.0.0.
- **Core Refactor (Nov 2025):** Major architectural cleanup moving audio logic to a dedicated engine.
+- **Added ReSpeaker 2-Mic Button Support:** The GPIO button (17) can be enabled via the config.json file. See [the tutorial](docs/linux-voice-assistant-2mic-install.md).
- **Changed Alarm to include a Duration Setting:** Added a MQTT control to make the alarm duration adjustable.
- **Performance Optimization:** Optimized audio threading for lower CPU usage when muted and improved responsiveness (reduced latency) when unmuted.
- **Non-Blocking Operations:** Wake word model downloads are now handled in background threads, preventing the device (LEDs/MQTT) from freezing during configuration updates.
diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md
index 3a08394..826b795 100644
--- a/docs/linux-voice-assistant-2mic-install.md
+++ b/docs/linux-voice-assistant-2mic-install.md
@@ -498,6 +498,82 @@ systemctl --user status linux-voice-assistant --no-pager -l
```
+
+Optional (GPIO Button)
+
+Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like:
+
+***Short press***
+
+If TTS or music is playing → stop playback (equivalent to the Stop wake word)
+
+Otherwise → start a new conversation (equivalent to a wake word trigger)
+
+***Long press***
+
+Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync)
+
+The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
+
+**Requires a compatible GPIO board** This has been tested on the ReSpeaker 2-Mic Pi Hat:
+
+**Edit the LVA config.json file:**
+
+```bash
+nano ~/linux-voice-assistant/linux_voice_assistant/config.json
+```
+
+**Enable the GPIO button support:**
+
+```bash
+"button": {
+ "enabled": true,
+ "pin": 17,
+ "long_press_seconds": 1.0
+ }
+```
+
+**Example (LVA config.json file with MQTT, Grove Port, and GPIO Button enabled** ***Note: The GPIO button can be changed from the default (17) on the 2-Mic hat.***
+
+```bash
+{
+ "app": {
+ "name": "Linux Voice Assistant"
+ },
+ "mqtt": {
+ "host": "192.168.1.2",
+ "port": 1883,
+ "username": "mqtt_username",
+ "password": "mqtt_password"
+ },
+ "led": {
+ "led_type": "dotstar",
+ "interface": "gpio",
+ "clock_pin": 13,
+ "data_pin": 12,
+ "num_leds": 10
+ },
+ "button": {
+ "enabled": true,
+ "pin": 17,
+ "long_press_seconds": 1.0
+ }
+}
+```
+
+**Enable & start:**
+
+```bash
+systemctl --user restart linux-voice-assistant.service
+```
+
+**Verify:**
+
+```bash
+systemctl --user status linux-voice-assistant --no-pager -l
+```
+
+
## 6. Connect to Home Assistant
### If HA does not discover the new LVA:
diff --git a/docs/lva-desktop.md b/docs/lva-desktop.md
index 789f152..77e3969 100644
--- a/docs/lva-desktop.md
+++ b/docs/lva-desktop.md
@@ -54,6 +54,10 @@ git clone https://github.com/imonlinux/linux-voice-assistant.git
cd linux-voice-assistant/
chmod +x script/tray
script/setup
+
+source .venv/bin/activate
+pip install -e .[tray]
+deactivate
```
### 5. Modify config.json File:
diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py
index 29f93de..6381589 100644
--- a/linux_voice_assistant/__main__.py
+++ b/linux_voice_assistant/__main__.py
@@ -15,6 +15,7 @@ from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
from pyopen_wakeword import OpenWakeWord
from .audio_engine import AudioEngine
+from .button_controller import ButtonController
from .config import Config, load_config_from_json
from .event_bus import EventBus, EventHandler, subscribe
from .led_controller import LedController
@@ -41,6 +42,7 @@ class WakeWordData:
active: Set[str]
stop_model: MicroWakeWord
+
@dataclass
class MediaPlayers:
"""Dataclass to hold media player instances."""
@@ -107,7 +109,7 @@ class MicMuteHandler(EventHandler):
{ "alarm_duration_seconds": }
Semantics:
- 0 -> infinite alarm (only stop via Stop wake word / wake word logic)
+ 0 -> infinite alarm (only Stop/wake word stops it)
>0 -> auto-stop alarm after N seconds (plus Stop wake word support)
"""
duration = data.get("alarm_duration_seconds")
@@ -424,6 +426,22 @@ def _init_controllers(
mqtt_controller=mqtt_controller,
)
+ # Hardware button controller (e.g. ReSpeaker 2-Mic HAT)
+ try:
+ if getattr(config, "button", None) is not None and config.button.enabled:
+ button_controller = ButtonController(
+ loop=loop,
+ event_bus=event_bus,
+ state=state,
+ config=config.button,
+ )
+ # Keep a reference on state so it is not garbage-collected.
+ setattr(state, "button_controller", button_controller)
+ else:
+ _LOGGER.debug("ButtonController not enabled in config; skipping")
+ except Exception:
+ _LOGGER.exception("Failed to initialize ButtonController")
+
async def _run_server(state: ServerState, config: Config):
"""Starts the ESPHome server and ZeroConf discovery."""
server = await state.loop.create_server(
diff --git a/linux_voice_assistant/button_controller.py b/linux_voice_assistant/button_controller.py
new file mode 100644
index 0000000..a621266
--- /dev/null
+++ b/linux_voice_assistant/button_controller.py
@@ -0,0 +1,223 @@
+"""Hardware button controller (e.g. ReSpeaker 2-Mic HAT momentary switch).
+
+This controller:
+- Short press:
+ * If audio is playing (TTS or music): stop playback (like Stop wake word).
+ * Otherwise: start a new conversation (like a wake word).
+- Long press:
+ * Toggle microphone mute (via the existing set_mic_mute event).
+
+Implementation note:
+- Uses a simple polling loop with RPi.GPIO instead of kernel edge detection.
+- This avoids "Failed to add edge detection" errors on some HATs/overlays.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+import time
+from dataclasses import dataclass
+
+from .event_bus import EventBus
+from .models import ServerState
+
+_LOGGER = logging.getLogger(__name__)
+
+try:
+ import RPi.GPIO as GPIO
+except ImportError: # pragma: no cover - non-RPi hosts
+ GPIO = None
+
+
+@dataclass
+class ButtonRuntimeConfig:
+ """Runtime-safe config wrapper for the hardware button."""
+ enabled: bool
+ pin: int
+ long_press_seconds: float
+ poll_interval_seconds: float = 0.01 # 100 Hz polling
+
+
+class ButtonController:
+ """
+ Hardware button handler for the ReSpeaker 2-Mic HAT (or any GPIO button).
+
+ This class:
+ - Uses a background polling thread to watch the GPIO level.
+ - Interprets level transitions as press/release.
+ - Schedules actions on the asyncio loop via loop.call_soon_threadsafe().
+ - Uses the EventBus to toggle mic mute.
+ """
+
+ def __init__(
+ self,
+ loop,
+ event_bus: EventBus,
+ state: ServerState,
+ config,
+ ) -> None:
+ """
+ :param loop: asyncio event loop (state.loop).
+ :param event_bus: Global EventBus.
+ :param state: Global ServerState.
+ :param config: Config.button (ButtonConfig-like object).
+ """
+ self.loop = loop
+ self.event_bus = event_bus
+ self.state = state
+
+ self._cfg = ButtonRuntimeConfig(
+ enabled=getattr(config, "enabled", False),
+ pin=getattr(config, "pin", 17),
+ long_press_seconds=float(
+ getattr(config, "long_press_seconds", 1.0)
+ ),
+ poll_interval_seconds=float(
+ getattr(config, "poll_interval_seconds", 0.01)
+ if hasattr(config, "poll_interval_seconds")
+ else 0.01
+ ),
+ )
+
+ self._thread: threading.Thread | None = None
+ self._press_time: float | None = None
+ self._last_level: int | None = None
+
+ if not self._cfg.enabled:
+ _LOGGER.info("ButtonController disabled in config; not initializing GPIO")
+ return
+
+ if GPIO is None:
+ _LOGGER.warning(
+ "RPi.GPIO not available; hardware button support disabled"
+ )
+ return
+
+ try:
+ GPIO.setmode(GPIO.BCM)
+ # Assume button wired as active-low with internal pull-up (common for HATs)
+ GPIO.setup(self._cfg.pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
+ self._last_level = GPIO.input(self._cfg.pin)
+ _LOGGER.info(
+ "ButtonController initialized on GPIO pin %s "
+ "(long_press_seconds=%.2f, poll_interval=%.3fs)",
+ self._cfg.pin,
+ self._cfg.long_press_seconds,
+ self._cfg.poll_interval_seconds,
+ )
+ except Exception:
+ _LOGGER.exception("Failed to configure GPIO pin %s for button", self._cfg.pin)
+ return
+
+ # Start polling thread
+ self._thread = threading.Thread(
+ target=self._poll_loop,
+ name="ButtonControllerThread",
+ daemon=True,
+ )
+ self._thread.start()
+
+ # -------------------------------------------------------------------------
+ # Polling loop
+ # -------------------------------------------------------------------------
+
+ def _poll_loop(self) -> None:
+ """Background loop to poll the GPIO level and detect presses."""
+ _LOGGER.debug("ButtonController polling thread started")
+
+ while not getattr(self.state, "shutdown", False):
+ try:
+ level = GPIO.input(self._cfg.pin)
+ except Exception:
+ _LOGGER.exception("Error reading GPIO pin %s", self._cfg.pin)
+ break
+
+ if self._last_level is None:
+ self._last_level = level
+
+ # Active-low: 0 = pressed, 1 = released
+ if level != self._last_level:
+ if level == 0:
+ self._on_pressed()
+ else:
+ self._on_released()
+ self._last_level = level
+
+ time.sleep(self._cfg.poll_interval_seconds)
+
+ _LOGGER.debug("ButtonController polling thread exiting")
+ # Do NOT call GPIO.cleanup() here; other components may use GPIO as well.
+
+ # -------------------------------------------------------------------------
+ # "Interrupt-like" handlers called from polling thread
+ # -------------------------------------------------------------------------
+
+ def _on_pressed(self) -> None:
+ self._press_time = time.monotonic()
+ _LOGGER.debug("Button pressed (GPIO %s low)", self._cfg.pin)
+
+ def _on_released(self) -> None:
+ if self._press_time is None:
+ return
+
+ duration = time.monotonic() - self._press_time
+ self._press_time = None
+ _LOGGER.debug("Button released after %.3f seconds", duration)
+
+ if duration >= self._cfg.long_press_seconds:
+ self._handle_long_press()
+ else:
+ self._handle_short_press()
+
+ # -------------------------------------------------------------------------
+ # High-level behaviors
+ # -------------------------------------------------------------------------
+
+ def _handle_short_press(self) -> None:
+ """Short press: wake / stop behavior."""
+ # Snapshot playing state quickly
+ tts_playing = bool(getattr(self.state.tts_player, "is_playing", False))
+ music_playing = bool(getattr(self.state.music_player, "is_playing", False))
+
+ if tts_playing or music_playing:
+ _LOGGER.debug(
+ "Button short press while audio playing "
+ "(tts=%s, music=%s) -> stopping playback",
+ tts_playing,
+ music_playing,
+ )
+
+ # Stop TTS/timer via the same logic as the Stop wake word.
+ if self.state.satellite is not None:
+ self.loop.call_soon_threadsafe(self.state.satellite.stop)
+
+ # Also stop any music playback controlled by the LVA.
+ self.loop.call_soon_threadsafe(self.state.music_player.stop)
+ else:
+ _LOGGER.debug(
+ "Button short press with no audio playing -> manual wakeup"
+ )
+ if self.state.satellite is not None:
+ # Use a dedicated manual wakeup to avoid faking a wake-word object.
+ self.loop.call_soon_threadsafe(
+ self.state.satellite.manual_wakeup, "button"
+ )
+
+ def _handle_long_press(self) -> None:
+ """Long press: toggle mic mute."""
+ new_state = not self.state.mic_muted
+ _LOGGER.debug(
+ "Button long press: toggling mic mute -> %s", new_state
+ )
+
+ # Use the standard set_mic_mute event so MicMuteHandler can:
+ # - update ServerState.mic_muted
+ # - control the mic_muted_event
+ # - publish MQTT mute state
+ self.loop.call_soon_threadsafe(
+ self.event_bus.publish,
+ "set_mic_mute",
+ {"state": new_state},
+ )
+
diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py
index 74e453b..ce5bf5c 100644
--- a/linux_voice_assistant/config.py
+++ b/linux_voice_assistant/config.py
@@ -5,6 +5,9 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
+import logging
+_LOGGER = logging.getLogger(__name__)
+
# -----------------------------------------------------------------------------
# Configuration Dataclasses
# -----------------------------------------------------------------------------
@@ -16,6 +19,7 @@ class AudioConfig:
input_block_size: int = 1024
output_device: Optional[str] = None
+
@dataclass
class WakeWordConfig:
"""Settings for wake word detection."""
@@ -25,12 +29,14 @@ class WakeWordConfig:
refractory_seconds: float = 2.0
download_dir: str = "local"
+
@dataclass
class ESPHomeConfig:
"""Settings for the ESPHome API server."""
host: str = "0.0.0.0"
port: int = 6053
+
@dataclass
class LedConfig:
"""Settings for the LED strip."""
@@ -38,7 +44,8 @@ class LedConfig:
interface: str = "spi"
clock_pin: int = 13
data_pin: int = 12
- num_leds: int = 3 # Note: This will be overridden by 'preferences.json' if it exists
+ num_leds: int = 3 # Note: overridden by 'preferences.json' if it exists
+
@dataclass
class MqttConfig:
@@ -49,6 +56,17 @@ class MqttConfig:
username: Optional[str] = None
password: Optional[str] = None
+
+@dataclass
+class ButtonConfig:
+ """Settings for a hardware momentary button (e.g. ReSpeaker 2-Mic HAT)."""
+ enabled: bool = False
+ # BCM GPIO pin number for the button input.
+ pin: int = 17
+ # Press duration (in seconds) to be considered a "long press".
+ long_press_seconds: float = 1.0
+
+
@dataclass
class AppConfig:
"""General application settings."""
@@ -58,6 +76,7 @@ class AppConfig:
preferences_file: str = "preferences.json"
debug: bool = False
+
@dataclass
class Config:
"""Main configuration object."""
@@ -67,6 +86,7 @@ class Config:
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
led: LedConfig = field(default_factory=LedConfig)
mqtt: MqttConfig = field(default_factory=MqttConfig)
+ button: ButtonConfig = field(default_factory=ButtonConfig)
# -----------------------------------------------------------------------------
# Helper Function
@@ -74,7 +94,7 @@ class Config:
def load_config_from_json(config_path: Path) -> Config:
"""Loads configuration from a JSON file and populates dataclasses."""
-
+
# --- Step 1: Load raw JSON data ---
try:
with open(config_path, "r", encoding="utf-8") as f:
@@ -88,15 +108,18 @@ def load_config_from_json(config_path: Path) -> Config:
# --- Step 2: Create config objects from raw data ---
if "app" not in raw_data:
- raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
-
+ raise ValueError(
+ "Configuration file must contain an 'app' section with a 'name'."
+ )
+
app_config = AppConfig(**raw_data.get("app", {}))
audio_config = AudioConfig(**raw_data.get("audio", {}))
wake_word_config = WakeWordConfig(**raw_data.get("wake_word", {}))
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
led_config = LedConfig(**raw_data.get("led", {}))
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
-
+ button_config = ButtonConfig(**raw_data.get("button", {}))
+
# --- Step 3: Set MQTT 'enabled' flag ---
if mqtt_config.host:
mqtt_config.enabled = True
@@ -109,7 +132,5 @@ def load_config_from_json(config_path: Path) -> Config:
esphome=esphome_config,
led=led_config,
mqtt=mqtt_config,
+ button=button_config,
)
-
-import logging
-_LOGGER = logging.getLogger(__name__)
diff --git a/linux_voice_assistant/satellite.py b/linux_voice_assistant/satellite.py
index 9ddd34d..9c929a0 100644
--- a/linux_voice_assistant/satellite.py
+++ b/linux_voice_assistant/satellite.py
@@ -478,6 +478,16 @@ class VoiceSatelliteProtocol(APIServer):
)
self._stop_timer_alarm("auto_timeout")
+ def _start_conversation(self, wake_word_phrase: str) -> None:
+ """Shared helper to start a new conversation run."""
+ _LOGGER.debug("Starting conversation: %s", wake_word_phrase)
+ self.send_messages(
+ [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
+ )
+ self._set_state(SatelliteState.LISTENING)
+ self._is_streaming_audio = True
+ self.state.tts_player.play(self.state.wakeup_sound)
+
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
# Existing behavior: ignore wakeup in other states.
@@ -491,12 +501,22 @@ class VoiceSatelliteProtocol(APIServer):
wake_word_phrase = getattr(wake_word, "wake_word", "wake word")
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
- self.send_messages(
- [VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
- )
- self._set_state(SatelliteState.LISTENING)
- self._is_streaming_audio = True
- self.state.tts_player.play(self.state.wakeup_sound)
+ self._start_conversation(wake_word_phrase)
+
+ def manual_wakeup(self, phrase: str = "button") -> None:
+ """
+ Manual wakeup entrypoint (e.g. hardware button) that behaves like a
+ wake word, but without requiring a wake-word model instance.
+ """
+ if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
+ return
+
+ if self._timer_finished:
+ self._stop_timer_alarm("button")
+ return
+
+ _LOGGER.debug("Manual wakeup triggered: %s", phrase)
+ self._start_conversation(phrase)
def stop(self) -> None:
"""
diff --git a/pyproject.toml b/pyproject.toml
index bf47fda..b1de8ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
]
requires-python = ">=3.9.0"
dependencies = [
@@ -39,8 +40,6 @@ dependencies = [
"RPi.GPIO",
"gpiozero",
"spidev",
- "PyQt5",
- "PyQt6",
]
[project.optional-dependencies]
@@ -52,6 +51,11 @@ dev = [
"pytest",
]
+# Optional GUI/tray dependencies. Only needed on desktop installs that use lva_tray_client.
+tray = [
+ "PyQt5>=5.15",
+]
+
[project.urls]
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"