From 93bdb60fc00fbb16e47b4d8ddc1ea64cabba706b Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:39:13 -0800 Subject: [PATCH 1/8] Update README.md Updated to include support for the gpio button. --- README.md | 1 + 1 file changed, 1 insertion(+) 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. From edc66183c3d493ec984a9e2a2adf9e422eb72c3d Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:40:16 -0800 Subject: [PATCH 2/8] Update config.py Added a new ButtonConfig dataclass: enabled: bool = False pin: int = 17 (BCM pin for the button) long_press_seconds: float = 1.0 Extended the main Config dataclass with a button: ButtonConfig field. Updated load_config_from_json() to construct button_config from the "button" section in config.json. This allows enabling/disabling the button, changing the GPIO pin, and tuning the long-press threshold (and optional poll interval) without code changes. --- linux_voice_assistant/config.py | 37 ++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) 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__) From dc9049e899b98f070251bf64aa13f0dabc43bbda Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:41:24 -0800 Subject: [PATCH 3/8] Add button_controller.py Introduced a new ButtonController responsible for handling the ReSpeaker (or generic GPIO) button: Uses RPi.GPIO with BCM numbering and internal pull-up (PUD_UP), assuming an active-low button. Starts a dedicated polling thread that: Reads the GPIO level at a configurable interval (default 10 ms / 100 Hz), Detects press and release transitions, Computes press duration to classify short vs long press. Short press behavior: If state.tts_player.is_playing or state.music_player.is_playing: Schedules state.satellite.stop() on the asyncio loop, Also stops music via state.music_player.stop(). Otherwise: Schedules state.satellite.manual_wakeup("button") to start a new voice interaction (wake-like behavior without needing a model instance). Long press behavior: Toggles mic mute using the existing event path: Publishes set_mic_mute on the EventBus with {"state": not state.mic_muted}. This ensures MicMuteHandler remains the single writer for mic state, updates mic_muted_event, and syncs MQTT mute state/LEDs. Designed to fail gracefully: If ButtonConfig.enabled is False or RPi.GPIO is unavailable, logs a message and disables button support without affecting the rest of the system. --- linux_voice_assistant/button_controller.py | 223 +++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 linux_voice_assistant/button_controller.py 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}, + ) + From 0073dc6a23b20d0020372952b595ccac6c1ae994 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:42:24 -0800 Subject: [PATCH 4/8] Update __main__.py Imported the new ButtonController. In _init_controllers(): Instantiated ButtonController when config.button.enabled is True. Passed loop, event_bus, state, and config.button. Stored the instance on state.button_controller to keep it alive for the lifetime of the process. Left the rest of startup orchestration unchanged (audio engine, MQTT, LEDs, etc.), so the button is just an additional input surface plugged into the existing architecture. --- linux_voice_assistant/__main__.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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( From a9cf822136be56cbb6defbbfee74b076ae0afcaf Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:43:08 -0800 Subject: [PATCH 5/8] Update satellite.py Extended the satellite state machine with a manual wake entrypoint: Added manual_wakeup(self, phrase: str = "button"): Mirrors the semantics of wakeup(), but does not require a wake-word model instance. Respects the current state: only triggers from STARTING or IDLE. If a timer alarm is ringing, calls the existing _stop_timer_alarm(...) instead of starting a new conversation (consistent with wake-word behavior). Otherwise calls a shared helper _start_conversation(phrase). Introduced _start_conversation(wake_word_phrase: str) to encapsulate: Sending VoiceAssistantRequest(start=True, wake_word_phrase=...), Transitioning to LISTENING, Setting _is_streaming_audio = True, Playing the configured wakeup_sound via tts_player. Kept prior timer/alarm logic intact (including auto stop and Stop wake word behavior), so the button integrates cleanly with existing timer and TTS flows. --- linux_voice_assistant/satellite.py | 32 ++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) 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: """ From fa3889805e55f3c1f17eacfef1d90f0251287948 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:00:35 -0800 Subject: [PATCH 6/8] Update linux-voice-assistant-2mic-install.md Update to include support of implementation of the GPIO button function. --- docs/linux-voice-assistant-2mic-install.md | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) 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: From 6260bf1dca555aedf99a785c391e2aea1a9d4ec1 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:33:08 -0800 Subject: [PATCH 7/8] Update pyproject.toml Fix regression bug introduced with the lva tray client. --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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" From 5773f3a35abbf9a0123f269124c3ac9d7f9cac84 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:37:31 -0800 Subject: [PATCH 8/8] Update lva-desktop.md Update to include the additional dependencies in the .venv for Qt5. --- docs/lva-desktop.md | 4 ++++ 1 file changed, 4 insertions(+) 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: