From 471ecea811b63d2b6d7bcb48db7e431d09b71a65 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:50:49 -0800 Subject: [PATCH 01/59] Create xvf3800_probe.py --- tests/xvf3800_probe.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/xvf3800_probe.py diff --git a/tests/xvf3800_probe.py b/tests/xvf3800_probe.py new file mode 100644 index 0000000..4de7a96 --- /dev/null +++ b/tests/xvf3800_probe.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +import argparse +import time + +import numpy as np +import sounddevice as sd + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "--device", + help="Exact input device name to test (as shown by --list-input-devices)", + required=True, + ) + parser.add_argument( + "--samplerate", + type=float, + default=16000.0, + help="Sample rate to test (default: 16000)", + ) + parser.add_argument( + "--seconds", + type=float, + default=2.0, + help="Duration to record for debug (default: 2s)", + ) + args = parser.parse_args() + + print("=== sounddevice.query_devices() (input subset) ===") + devices = sd.query_devices() + for idx, dev in enumerate(devices): + if dev["max_input_channels"] > 0: + flag = "*" if args.device in (dev["name"], str(idx)) else " " + print( + f"{flag} [{idx}] {dev['name']} " + f"(max_input_channels={dev['max_input_channels']}, " + f"default_samplerate={dev['default_samplerate']})" + ) + + print("\n=== Opening stream ===") + sd.default.samplerate = args.samplerate + sd.default.dtype = "int16" + + # Allow using either index or full name + device = args.device + try: + device = int(args.device) + except ValueError: + pass + + # First just open/close the stream to see what PortAudio says + with sd.InputStream(device=device, channels=0) as stream: + print(f"Opened stream with device={device}") + print(f" samplerate: {stream.samplerate}") + print(f" channels: {stream.channels}") + print(f" dtype: {stream.dtype}") + print(f" blocksize: {stream.blocksize}") + + # Now actually record a short block and inspect the shape + print("\n=== Recording test block ===") + duration = args.seconds + num_frames = int(duration * args.samplerate) + print(f"Recording {duration} seconds ({num_frames} frames)...") + + recorded = sd.rec(frames=num_frames, samplerate=args.samplerate, + channels=0, dtype="int16", device=device) + sd.wait() + + arr = np.array(recorded) + print(f"Recorded array shape: {arr.shape}, dtype={arr.dtype}") + if arr.ndim == 1: + print("-> Mono (1D) array returned") + elif arr.ndim == 2: + print(f"-> Multichannel: {arr.shape[1]} channels") + else: + print("-> Unexpected ndim:", arr.ndim) + + print(f"Sample min/max: {arr.min()} / {arr.max()}") + + +if __name__ == "__main__": + main() From 97da7f2d0f4aeafbb5460f68402c4095cc92acdb Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Fri, 5 Dec 2025 19:50:01 -0800 Subject: [PATCH 02/59] Initial draft xvf3800 tutorial --- docs/linux-voice-assistant-xvf3800.md | 403 ++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 docs/linux-voice-assistant-xvf3800.md diff --git a/docs/linux-voice-assistant-xvf3800.md b/docs/linux-voice-assistant-xvf3800.md new file mode 100644 index 0000000..38d4154 --- /dev/null +++ b/docs/linux-voice-assistant-xvf3800.md @@ -0,0 +1,403 @@ +# Linux Voice Assistant with ReSpeaker XVF3800 4‑Mic Array — Installation & Configuration Guide + +> Drafted with ChatGPT based on existing `linux-voice-assistant-2mic-install.md` and +> community test results from the XVF3800 integration Discussion. + +This guide shows how to use the **Seeed ReSpeaker XVF3800 USB 4‑Mic Array** with the +**linux-voice-assistant** (LVA) project on Raspberry Pi or other Linux hosts. + +Unlike the ReSpeaker 2‑Mic HAT, the XVF3800 is a **USB Audio Class 2.0** device: +it acts as a “smart microphone + speaker” front end with built‑in **AEC, beamforming, +noise suppression, AGC, and echo‑canceling speakerphone** processing. + +LVA treats the XVF3800 as a **standard USB mic/speaker**: +- Audio in: 16 kHz, 16‑bit, 2‑channel PCM (downmixed to mono by the audio stack) +- Audio out: 16 kHz, 16‑bit, 2‑channel PCM via the XVF3800 headphone jack / speaker + +No custom kernel drivers are required. Plug it in, select it as your audio device in +LVA’s `config.json`, and it will “just work” as a high‑quality mic for wake word and +ESPHome streaming. + +--- + +## Prerequisites + +- A Linux host (e.g., Raspberry Pi 4/5) running one of: + - Raspberry Pi OS (Bookworm or Trixie) + - Debian 13 (trixie) or similar + - Other modern Linux with PipeWire or PulseAudio +- Python 3.11+ recommended +- **ReSpeaker XVF3800 4‑Mic Array**, connected via its **XMOS USB‑C port** +- Network access to your Home Assistant instance +- The `linux-voice-assistant` repository checked out (optionally on the `XVF3800` branch) + +--- + +## 1. Get the code (XVF3800 branch) + +If you don’t already have the project cloned, you can pull the dedicated XVF3800 branch: + +```bash +cd ~ +git clone --branch XVF3800 --single-branch https://github.com/imonlinux/linux-voice-assistant.git +cd linux-voice-assistant +``` + +If you already have the repository, you can switch branches: + +```bash +cd ~/linux-voice-assistant +git fetch origin +git checkout XVF3800 +``` + +Set up the project (creates a virtualenv, installs dependencies, etc.): + +```bash +cd ~/linux-voice-assistant +script/setup +``` + +> Tip: If you use a different username than `pi`, adjust paths accordingly. + +--- + +## 2. Connect and verify the XVF3800 + +### 2.1 Plug in the board + +1. Connect the XVF3800 to your Pi/host using the **XMOS USB‑C port** (the one next to + the 3.5 mm audio jack). +2. If you want local audio output, connect the XVF3800’s headphone jack to an external + powered speaker. + +### 2.2 Confirm ALSA sees the device + +```bash +arecord -l +aplay -l +``` + +You should see entries similar to: + +```text +**** List of CAPTURE Hardware Devices **** +card 3: Array [reSpeaker XVF3800 4-Mic Array], device 0: USB Audio [USB Audio] + Subdevices: 0/1 + Subdevice #0: subdevice #0 + +**** List of PLAYBACK Hardware Devices **** +card 3: Array [reSpeaker XVF3800 4-Mic Array], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +``` + +The exact card index (`3` above) may differ on your system. + +### 2.3 Quick `arecord` test (optional) + +Replace `3` with your card number: + +```bash +arecord -D plughw:3,0 -c 2 -r 16000 -f S16_LE -d 5 -vv /tmp/xvf3800-test.wav +aplay /tmp/xvf3800-test.wav +``` + +You should hear your voice through your default playback device, confirming that: +- The XVF3800 is streaming at **16 kHz**, **16‑bit**, **stereo**, and +- ALSA can capture from it reliably. + +--- + +## 3. PipeWire / PulseAudio device mapping + +On a PipeWire or PulseAudio system, check how the XVF3800 shows up at the graph level: + +```bash +pactl list short sources +pactl list short sinks +``` + +Typical output: + +```text +# Sources +309 alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...-00.analog-stereo PipeWire s16le 2ch 16000Hz RUNNING + +# Sinks +308 alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...-00.analog-stereo PipeWire s16le 2ch 16000Hz SUSPENDED +``` + +Key points: + +- The XVF3800 appears as an **analog stereo** source/sink (`2ch 16000 Hz`). +- LVA will use these devices through the `soundcard` library. + +--- + +## 4. LVA’s view of the XVF3800 (input/output devices) + +From the project root, use LVA’s helper flags to inspect devices: + +```bash +cd ~/linux-voice-assistant +script/run -- --list-input-devices +script/run -- --list-output-devices +``` + +Example output: + +```text +Input devices + +[0] reSpeaker XVF3800 4-Mic Array Analog Stereo +``` + +```text +Output devices + +auto: Autoselect device +pipewire: Default (pipewire) +pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...analog-stereo: reSpeaker XVF3800 4-Mic Array Analog Stereo +... +``` + +Take note of: + +- The **input device name** for the XVF3800 (e.g. `reSpeaker XVF3800 4-Mic Array Analog Stereo`) +- The output device you want to use (often `pipewire` or the specific XVF “analog‑stereo” sink) + +--- + +## 5. Configure LVA audio for XVF3800 + +Edit the main config: + +```bash +nano ~/linux-voice-assistant/linux_voice_assistant/config.json +``` + +Add or update the `audio` section: + +```json +"audio": { + "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", + "output_device": "pipewire", + "input_block_size": 1024 +} +``` + +Examples: + +- If you want both input and output on the XVF3800: + + ```json + "audio": { + "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", + "output_device": "pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441253700076-00.analog-stereo", + "input_block_size": 1024 + } + ``` + +- If you only want to use the XVF3800 as a **mic** (and another device for playback), set + `output_device` to your preferred sink from `--list-output-devices`. + +### 5.1 How channels are handled (important detail) + +The XVF3800 exposes itself as a **2‑channel** source (stereo, 16 kHz). Internally, the +LVA `AudioEngine` uses the `soundcard` library and **always requests 1 channel** from the +selected input device: + +```python +with self.mic.recorder( + samplerate=16000, + channels=1, + blocksize=self.block_size, +) as mic_in: + audio_chunk_array = mic_in.record(self.block_size).reshape(-1) + ... +``` + +This has two important implications: + +1. The audio stack (PipeWire/PulseAudio/ALSA) takes care of converting from the XVF3800’s + **2‑channel** stream to a **mono** stream before LVA sees it. +2. LVA’s wake‑word engines and ESPHome streaming always receive 16 kHz **mono** PCM + (`int16`), regardless of whether the underlying hardware is mono or stereo. + +In practice: + +> You do **not** need any XVF3800‑specific code changes in the audio engine. +> Selecting the XVF3800 as the `audio.input_device` is sufficient; LVA will process it +> as a mono mic just like other devices. + +--- + +## 6. Start LVA as a user-mode service + +The service setup is identical to other configurations (2‑Mic, etc.). Assuming you want +LVA to run as a user‑mode systemd service: + +### 6.1 Enable linger for your user (e.g. `pi`) + +```bash +sudo loginctl enable-linger pi +``` + +### 6.2 Install the systemd user service + +```bash +mkdir -p ~/.config/systemd/user +cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service +``` + +### 6.3 Enable & start + +```bash +systemctl --user daemon-reload +systemctl --user enable --now linux-voice-assistant.service +``` + +Check status: + +```bash +systemctl --user status linux-voice-assistant --no-pager -l +``` + +Look for log lines indicating: + +- LVA connected to Home Assistant +- Wake‑word engine initialized +- Satellite streaming started on wake word + +--- + +## 7. Acoustic Echo Cancellation (AEC) considerations + +The XVF3800 already implements **hardware AEC / echo‑canceling speakerphone** processing. +In many cases, you can rely **only** on the XVF3800’s built‑in AEC and **skip** the +PipeWire/PulseAudio `module-echo-cancel` setup. + +You have three practical options: + +### 7.1 Rely on XVF3800 AEC only (recommended starting point) + +- Do **not** load any user‑mode echo‑cancel module. +- Set `input_device` to the XVF3800 source (or an echo‑cancel source that wraps it, if your + system has one by default). +- Play media (music, games, etc.) through the XVF3800 or another output and test: + - Wake word recognition reliability + - STT quality in noisy playback conditions + +If it works well, this is the simplest and most CPU‑efficient setup. + +### 7.2 Combine XVF3800 with software AEC + +If you already have a PipeWire/PulseAudio echo‑cancel device configured, you can route +XVF3800 through it: + +1. Create an echo‑cancel source/sink pair (see `install_pipewire.md` or + `install_pulseaudio.md`). +2. Use `script/run --list-input-devices` / `--list-output-devices` to find names like + `Echo-Cancel Source` and `pipewire/echo-cancel-sink`. +3. Point LVA at those devices in `config.json`: + + ```json + "audio": { + "input_device": "Echo-Cancel Source", + "input_block_size": 1024, + "output_device": "pipewire/echo-cancel-sink" + } + ``` + +This stacks XVF3800’s hardware processing with additional software AEC. It may help in +particularly difficult acoustic environments, at the cost of some complexity and CPU. + +### 7.3 No AEC at all (not recommended for open‑air speakers) + +You can also run without any AEC (hardware or software) if: + +- You use **headphones** instead of speakers, or +- The device isn’t playing audio while listening (e.g. strictly microphone‑only role). + +In that case, you can treat XVF3800 as “just another USB mic” and ignore its speakerphone +capabilities. + +--- + +## 8. Troubleshooting & debug tips + +### 8.1 Check that LVA sees the XVF3800 + +Re‑run: + +```bash +cd ~/linux-voice-assistant +script/run -- --list-input-devices +script/run -- --list-output-devices +``` + +If `reSpeaker XVF3800 4-Mic Array Analog Stereo` doesn’t appear: + +- Check `arecord -l` / `aplay -l` again. +- Ensure the XVF3800 is connected to the XMOS USB‑C port and powered. +- Check `lsusb` for a device with `2886:001a`: + + ```bash + lsusb | grep -i 2886 + ``` + +### 8.2 Tail the LVA logs + +```bash +journalctl --user -u linux-voice-assistant.service -f +``` + +Look for: + +- “Audio engine started.” +- “Opening audio input device: reSpeaker XVF3800 4-Mic Array Analog Stereo” +- Wake‑word detection lines (MicroWakeWord / OpenWakeWord) + +### 8.3 Optional: XVF3800 probe script + +On the `XVF3800` branch, there is a small probe script in `tests/xvf3800_probe.py` that +can be used to inspect how the audio backend opens the device. + +For example: + +```bash +cd ~/linux-voice-assistant +python tests/xvf3800_probe.py --device "reSpeaker XVF3800 4-Mic Array Analog Stereo" +``` + +This will print: + +- The device list from the `soundcard` backend +- The samplerate and channel count used when opening the stream +- The shape and dtype of the recorded buffer + +It’s mainly a debug tool for development and Discussion threads; you don’t need it for +normal operation. + +--- + +## 9. Next steps: LEDs, button, and DOA integration + +This document covers **audio‑only** integration for the XVF3800. The board also has: + +- A 12‑LED WS2812 ring +- A hardware mute button and mute LED +- Direction‑of‑arrival (DOA) and voice activity indicators + +Future work in the LVA project may add an optional `Xvf3800Controller` that: + +- Talks to the XVF3800 over USB using a Python SDK (pyusb or HID) +- Publishes DOA, VAD, and mute button events to LVA’s internal event bus +- Synchronizes mute state and LED behavior between XVF3800, LVA, and Home Assistant + +For now, the important takeaway is: + +> As soon as the XVF3800 is selected as the input device in `config.json`, the Linux +> Voice Assistant can use it as a high‑quality microphone front end with no additional +> code changes. From 44091bbc8bb78535a012976a87736b90b65f3aa4 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:32:30 -0800 Subject: [PATCH 03/59] Add files via upload What it does: Uses hidapi (pip install hidapi) to: Enumerate devices with VID/PID 0x2886:0x001a (Seeed / XVF3800). Open the first matching HID interface (or a specific --path). Read reports in a loop and print them as timestamps + hex blobs. You (or testers) run it, press/release the XVF mute button a few times, and paste the output into the Discussion so we can see which byte/bit is the mute state. --- tests/xvf3800_hid_mute_probe.py | 182 ++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/xvf3800_hid_mute_probe.py diff --git a/tests/xvf3800_hid_mute_probe.py b/tests/xvf3800_hid_mute_probe.py new file mode 100644 index 0000000..ce5b4d5 --- /dev/null +++ b/tests/xvf3800_hid_mute_probe.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""ReSpeaker XVF3800 HID mute/button probe. + +This script helps reverse-engineer how the XVF3800 exposes its mute button over USB HID. +It is meant to be run from the linux-voice-assistant virtualenv on a system where the +XVF3800 is connected via USB. + +Requirements: + pip install hidapi + +Usage examples: + + # Basic usage (auto-detect first 2886:001a HID interface) + python tests/xvf3800_hid_mute_probe.py + + # Explicit vendor/product (optional, defaults are correct for XVF3800) + python tests/xvf3800_hid_mute_probe.py --vendor-id 0x2886 --product-id 0x001a + +Once running, press and release the XVF3800 mute button a few times. The script will +print raw HID reports in hex. Share that output in the Discussion so we can identify +which byte/bit corresponds to the mute state. +""" + +import argparse +import binascii +import datetime as dt +import sys +import time +from typing import List, Optional + +try: + import hid # type: ignore[import] +except Exception as exc: # pragma: no cover - environment dependent + print("ERROR: Failed to import 'hid' (hidapi).", file=sys.stderr) + print(" Install it with: pip install hidapi", file=sys.stderr) + print(f" Details: {exc}", file=sys.stderr) + sys.exit(1) + + +def _fmt_vid_pid(vid: int, pid: int) -> str: + return f"0x{vid:04x}:0x{pid:04x}" + + +def list_matching_devices(vendor_id: int, product_id: int) -> List[dict]: + devices = [] + for dev in hid.enumerate(): + if dev.get("vendor_id") == vendor_id and dev.get("product_id") == product_id: + devices.append(dev) + return devices + + +def choose_device(devices: List[dict]) -> Optional[dict]: + """Pick a device from the list; if only one, return it, else pick the first. + + We log all candidates so users can see what's on their system. + """ + if not devices: + return None + + print("Found the following matching HID interfaces for XVF3800:") + for idx, dev in enumerate(devices): + path = dev.get("path") + serial = dev.get("serial_number") or "" + mfg = dev.get("manufacturer_string") or "" + prod = dev.get("product_string") or "" + iface = dev.get("interface_number", -1) + print( + f" [{idx}] path={path!r}, serial={serial!r}, iface={iface}, " + f"manufacturer={mfg!r}, product={prod!r}" + ) + + print() + print("NOTE: Using index 0 by default. If this is not the HID interface, ") + print(" rerun with --path from the list above.\n") + return devices[0] + + +def open_device( + vendor_id: int, + product_id: int, + path: Optional[bytes] = None, +) -> hid.device: + """Open a HID device for the given VID/PID (and optional path).""" + dev = hid.device() + if path is not None: + print(f"Opening HID device by path: {path!r}") + dev.open_path(path) + else: + print(f"Opening HID device by VID/PID: {_fmt_vid_pid(vendor_id, product_id)}") + dev.open(vendor_id, product_id) + + dev.set_nonblocking(True) + return dev + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Probe HID reports for the ReSpeaker XVF3800 mute button." + ) + parser.add_argument( + "--vendor-id", + type=lambda x: int(x, 0), + default=0x2886, + help="USB vendor ID (default: 0x2886 for Seeed)", + ) + parser.add_argument( + "--product-id", + type=lambda x: int(x, 0), + default=0x001a, + help="USB product ID (default: 0x001a for XVF3800)", + ) + parser.add_argument( + "--path", + type=str, + default="", + help="Optional exact HID path to open (overrides VID/PID selection)", + ) + parser.add_argument( + "--timeout-ms", + type=int, + default=500, + help="Read timeout in milliseconds (default: 500)", + ) + args = parser.parse_args() + + print("=== XVF3800 HID Mute Probe ===") + print(f"Vendor/Product: {_fmt_vid_pid(args.vendor_id, args.product_id)}") + + if args.path: + chosen_path: Optional[bytes] = args.path.encode("utf-8") + else: + devices = list_matching_devices(args.vendor_id, args.product_id) + if not devices: + print( + "No HID interfaces found for XVF3800 (VID/PID). Make sure the board is plugged in.", + file=sys.stderr, + ) + return 1 + chosen = choose_device(devices) + if chosen is None: + print("No device chosen.", file=sys.stderr) + return 1 + chosen_path = chosen.get("path") + print("Using device:") + print(f" path={chosen_path!r}") + print(f" serial={chosen.get('serial_number')!r}") + print(f" interface_number={chosen.get('interface_number')}") + print() + + try: + dev = open_device(args.vendor_id, args.product_id, chosen_path) + except Exception as exc: # pragma: no cover - hardware dependent + print(f"ERROR: Failed to open HID device: {exc}", file=sys.stderr) + return 1 + + print("Listening for HID reports. Press and release the XVF3800 mute button a few times.") + print("Press Ctrl+C to stop.\n") + + try: + while True: + data = dev.read(64, timeout_ms=args.timeout_ms) + if data: + now = dt.datetime.now().strftime("%H:%M:%S.%f")[:-3] + raw = bytes(data) + hex_str = binascii.hexlify(raw).decode("ascii") + # Trim trailing zeroes for readability + hex_str = hex_str.rstrip("0") or hex_str + print(f"[{now}] len={len(raw):02d} report={hex_str}") + time.sleep(0.01) + except KeyboardInterrupt: + print("\nStopping probe.") + finally: + try: + dev.close() + except Exception: + pass + + return 0 + + +if __name__ == "__main__": # pragma: no cover - script entry + raise SystemExit(main()) From 7305a289a0f0b662bf59b55300bdbdbdc025bb07 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:34:54 -0800 Subject: [PATCH 04/59] Add files via upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What’s in it: Xvf3800RuntimeConfig – simple dataclass for VID/PID, timeouts, etc. Xvf3800Controller(EventHandler) – structured very similar to your other controllers: On init: Checks enabled and hid availability. Uses hid.enumerate() to find the XVF3800 by VID/PID. Opens the first HID interface (non-blocking). Starts a background thread _poll_loop(). _poll_loop(): Reads HID reports (read(64, timeout_ms=...)). For each non-empty report: Calls _parse_hw_mute_from_report(report) → Optional[bool]. If it returns True/False and the value changed, it publishes: self.loop.call_soon_threadsafe( self.event_bus.publish, "set_mic_mute", {"state": new_state}, ) Exits cleanly when self._running is false or state.shutdown is true. @subscribe def set_mic_mute(self, data: dict): Listens to the same set_mic_mute event MQTT and ButtonController use. Updates _last_hw_mute_state. Calls _send_hw_mute_state(desired) to push mute+LED down to hardware. Placeholders: def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]: # TODO: implement once we know the report layout _LOGGER.debug("XVF3800 HID report (len=%d): %s", len(report), report.hex()) return None def _send_hw_mute_state(self, muted: bool) -> None: # TODO: implement once we know output report format _LOGGER.debug("(stub) Would send HID command to set XVF3800 mute=%s", muted) --- linux_voice_assistant/xvf3800_controller.py | 320 ++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 linux_voice_assistant/xvf3800_controller.py diff --git a/linux_voice_assistant/xvf3800_controller.py b/linux_voice_assistant/xvf3800_controller.py new file mode 100644 index 0000000..da87b9e --- /dev/null +++ b/linux_voice_assistant/xvf3800_controller.py @@ -0,0 +1,320 @@ +"""XVF3800 USB/HID controller scaffold. + +This module is intended to integrate the ReSpeaker XVF3800 mute button and +mute LED with the Linux Voice Assistant (LVA) mute state. + +Design goals: + - HW mute button -> LVA: + * When the user presses the XVF3800 mute button, the board mutes the + mic and lights the red LED. + * This controller detects the change over USB HID and publishes the + standard "set_mic_mute" event so the existing MicMuteHandler can: + - update ServerState.mic_muted + - update ServerState.mic_muted_event + - publish MQTT mute state + - notify the tray, etc. + + - LVA -> HW: + * When LVA toggles mute (via HA MQTT, ReSpeaker 2-Mic GPIO button, etc.), + this controller receives the same "set_mic_mute" event and sends a + USB HID command to synchronize the XVF3800's internal mute state and + red LED. + +This file is a scaffold: + - The USB/HID read/write paths are structured, but the actual report + format is not yet known. + - TODOs are marked where the mute bit must be decoded or set. + - Once the test script (xvf3800_hid_mute_probe.py) has revealed the + HID report layout, implement _parse_hw_mute_from_report() and + _send_hw_mute_state(). +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from typing import Optional + +from .event_bus import EventBus, EventHandler, subscribe +from .models import ServerState + +_LOGGER = logging.getLogger(__name__) + +try: # pragma: no cover - depends on platform / install + import hid # type: ignore[import] +except Exception: # ImportError, RuntimeError, etc. + hid = None # type: ignore[assignment] + + +@dataclass +class Xvf3800RuntimeConfig: + """Runtime configuration for the XVF3800 controller. + + This is intentionally simple; if we later add more XVF-specific options, + they can be added here and wired from config.json. + """ # noqa: D401 + + enabled: bool = True + vendor_id: int = 0x2886 + product_id: int = 0x001A + read_timeout_ms: int = 200 + poll_interval_seconds: float = 0.02 + + +class Xvf3800Controller(EventHandler): + """Controller for the ReSpeaker XVF3800 USB/HID mute integration. + + Responsibilities: + - Open the XVF3800 HID interface (if available and enabled). + - Run a background thread that reads HID reports and detects mute + state changes, publishing "set_mic_mute" events back into the LVA. + - Listen for "set_mic_mute" events and push desired mute state down + to the XVF3800 (muting the mic and controlling the red LED). + + Safe failure: + - If the 'hid' module is not available, or no matching XVF3800 device + is found, the controller logs an info message and stays inactive. + - All errors in the polling thread are logged but do not crash LVA. + """ + + def __init__( + self, + loop, + event_bus: EventBus, + state: ServerState, + config: Optional[Xvf3800RuntimeConfig] = None, + ) -> None: + super().__init__(event_bus) + + self.loop = loop + self.state = state + self._cfg = config or Xvf3800RuntimeConfig() + + self._thread: Optional[threading.Thread] = None + self._hid_dev: Optional["hid.device"] = None # type: ignore[name-defined] + self._last_hw_mute_state: Optional[bool] = None + self._running = False + + if not self._cfg.enabled: + _LOGGER.info("Xvf3800Controller disabled in config; not initializing HID") + return + + if hid is None: + _LOGGER.info( + "hidapi not available; XVF3800 mute integration disabled. " + "Install it with 'pip install hidapi' if you want this feature.", + ) + return + + # Attempt to open the XVF3800 HID interface. + try: + self._hid_dev = self._open_device( + self._cfg.vendor_id, + self._cfg.product_id, + ) + except Exception: + _LOGGER.exception( + "Failed to open XVF3800 HID device (VID=0x%04x PID=0x%04x); " + "mute integration disabled", + self._cfg.vendor_id, + self._cfg.product_id, + ) + self._hid_dev = None + + if self._hid_dev is None: + return + + self._running = True + self._thread = threading.Thread( + target=self._poll_loop, + name="Xvf3800ControllerThread", + daemon=True, + ) + self._thread.start() + + # Subscribe to events AFTER init + self._subscribe_all_methods() + + _LOGGER.info( + "Xvf3800Controller initialized (VID=0x%04x PID=0x%04x)", + self._cfg.vendor_id, + self._cfg.product_id, + ) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def stop(self) -> None: + """Stop the polling thread and close HID device (if any).""" + self._running = False + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=1.0) + if self._hid_dev is not None: + try: + self._hid_dev.close() + except Exception: + _LOGGER.exception("Error closing XVF3800 HID device") + _LOGGER.debug("Xvf3800Controller stopped") + + # ------------------------------------------------------------------ + # HID setup + # ------------------------------------------------------------------ + + def _open_device(self, vendor_id: int, product_id: int): + """Open the first HID interface that matches the XVF3800 VID/PID. + + If multiple HID interfaces are present, we currently pick the first + one returned by hid.enumerate(). If this proves unreliable, we can + refine selection using interface_number/product_string. + """ + devices = [ + dev + for dev in hid.enumerate() # type: ignore[attr-defined] + if dev.get("vendor_id") == vendor_id + and dev.get("product_id") == product_id + ] + + if not devices: + _LOGGER.warning( + "No HID interfaces found for XVF3800 (VID=0x%04x PID=0x%04x)", + vendor_id, + product_id, + ) + return None + + dev_info = devices[0] + path = dev_info.get("path") + _LOGGER.info( + "Opening XVF3800 HID device: path=%r serial=%r interface=%r", + path, + dev_info.get("serial_number"), + dev_info.get("interface_number"), + ) + + dev = hid.device() # type: ignore[call-arg] + if path is not None: + dev.open_path(path) + else: + dev.open(vendor_id, product_id) + + dev.set_nonblocking(True) + return dev + + # ------------------------------------------------------------------ + # Polling loop + # ------------------------------------------------------------------ + + def _poll_loop(self) -> None: + """Background loop reading HID reports and detecting mute changes.""" + _LOGGER.debug("Xvf3800Controller polling thread started") + + assert self._hid_dev is not None + + while self._running and not getattr(self.state, "shutdown", False): + try: + data = self._hid_dev.read( # type: ignore[union-attr] + 64, + timeout_ms=self._cfg.read_timeout_ms, + ) + except Exception: + _LOGGER.exception("Error reading from XVF3800 HID device") + break + + if data: + # 'data' is typically a list of ints; convert to bytes for parsing. + report = bytes(data) + new_state = self._parse_hw_mute_from_report(report) + if new_state is not None and new_state != self._last_hw_mute_state: + _LOGGER.debug( + "XVF3800 HW mute state changed: %s -> %s", + self._last_hw_mute_state, + new_state, + ) + self._last_hw_mute_state = new_state + + # Publish standard event; MicMuteHandler will take care + # of updating ServerState and emitting mic_muted/mic_unmuted. + self.loop.call_soon_threadsafe( + self.event_bus.publish, + "set_mic_mute", + {"state": new_state}, + ) + + time.sleep(self._cfg.poll_interval_seconds) + + _LOGGER.debug("Xvf3800Controller polling thread exiting") + + # ------------------------------------------------------------------ + # Parsing & writing HID reports (placeholders) + # ------------------------------------------------------------------ + + def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]: + """Extract the current mute state from a raw HID report. + + Returns: + True -> HW reports 'muted' + False -> HW reports 'unmuted' + None -> This report does not contain mute information + + TODO: Implement this once we know the HID report layout. For now, + it logs the report at DEBUG level and returns None. + """ + _LOGGER.debug( + "XVF3800 HID report (len=%d): %s", + len(report), + report.hex(), + ) + # Once we have sample outputs from xvf3800_hid_mute_probe.py, + # implement the bit extraction here. + return None + + def _send_hw_mute_state(self, muted: bool) -> None: + """Send a HID command to set the XVF3800 mute state + LED. + + TODO: Once we understand the HID output/report format (from docs or + reverse engineering), construct and write the correct report. + For now, this method just logs what it *would* do. + """ + if self._hid_dev is None: + return + + _LOGGER.debug( + "(stub) Would send HID command to set XVF3800 mute=%s", + muted, + ) + # Example once we know the correct output report: + # report_id = 0x01 + # payload = bytes([report_id, 0x01 if muted else 0x00, 0x00, 0x00, ...]) + # try: + # self._hid_dev.write(payload) + # except Exception: + # _LOGGER.exception("Error writing XVF3800 HID mute command") + + # ------------------------------------------------------------------ + # EventBus subscriptions + # ------------------------------------------------------------------ + + @subscribe + def set_mic_mute(self, data: dict) -> None: + """Handle global 'set_mic_mute' events. + + This is the *same* event that MQTT, ButtonController, etc. use. By + listening here, we can sync the XVF3800 hardware state with LVA's + logical mute state. + + The central MicMuteHandler is still the source of truth; this + controller should not modify ServerState directly, only attempt to + mirror that state to the hardware. + """ + desired = bool(data.get("state", False)) + _LOGGER.debug( + "Xvf3800Controller received set_mic_mute event: desired=%s", + desired, + ) + + # Remember the last state so we don't treat an echo from hardware as new. + self._last_hw_mute_state = desired + self._send_hw_mute_state(desired) From 6c65903425c5fa19bf66647e4f1a36c687c23a0c Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:09:22 -0800 Subject: [PATCH 05/59] Update README.md Updated to include XVF3800 support --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bee2784..b3efb73 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Supports announcments, start/continue conversation, and timers. See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to build a satellite using a [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) and a [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0). +The LVA now supports the ReSpeaker XVF3800 4 Mic array via the USB interface. See [this tutorial](docs/linux-voice-assistant-xvf3800.md). + Want to run the satellite on a Linux desktop using a simple tray client? See [this tutorial](docs/lva-desktop.md). ### What's working: From ebe93da7cbc15485815d3879a6b88ddabbe09da5 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:27:37 -0800 Subject: [PATCH 06/59] Add details on the XVF3800 mute button --- docs/linux-voice-assistant-xvf3800-mute.md | 223 +++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 docs/linux-voice-assistant-xvf3800-mute.md diff --git a/docs/linux-voice-assistant-xvf3800-mute.md b/docs/linux-voice-assistant-xvf3800-mute.md new file mode 100644 index 0000000..4c091b7 --- /dev/null +++ b/docs/linux-voice-assistant-xvf3800-mute.md @@ -0,0 +1,223 @@ +# XVF3800 Mute Button & USB Control Integration + +This document explains how the **ReSpeaker XVF3800 4‑Mic USB Array** integrates its +built‑in mute button and red mute LED with the **Linux Voice Assistant (LVA)**. + +The goal is: +- Pressing the XVF3800’s hardware mute button updates the LVA mic state. +- Muting/unmuting the LVA (via Home Assistant, MQTT, or other inputs) updates + the XVF3800’s mic mute circuit and red LED. +- No extra binaries (like the vendor `xvf_host`) are required — LVA talks to + the XVF3800 directly via **USB control transfers** using `pyusb`. + +--- + +## 1. How it Works (High‑Level) + +The XVF3800 exposes a vendor control protocol over USB that can read and write +internal GPIO pins (GPO). One of these pins drives both: + +- the microphone mute circuit +- the red mute LED on the XVF3800 front panel + +LVA’s `XVF3800ButtonController` does three things: + +1. **Reads hardware mute state periodically** + It polls the XVF3800’s GPO state (pin X0D30) in a background thread using + `pyusb`. If the value changes, LVA treats that as “the user pressed the + XVF3800 mute button” and publishes a `set_mic_mute` event. + +2. **Mirrors LVA mute → XVF3800** + It subscribes to `mic_muted` / `mic_unmuted` events. Whenever LVA’s mute + state changes (for example, via MQTT, HA UI, or a GPIO button), the + controller writes the new state back to X0D30 so that the mic mute circuit + and red LED stay in sync. + +3. **Initial sync on startup** + On first successful read, the controller treats the *hardware* mute state + as the source of truth and publishes `set_mic_mute` so LVA starts aligned + with whatever the XVF3800 is currently doing. + +No PulseAudio/PipeWire configuration is required for this feature; it only +depends on **USB access to the XVF3800** and the `pyusb` library. + +--- + +## 2. Prerequisites + +- You are running a branch of **linux‑voice‑assistant** that includes: + - `linux_voice_assistant/xvf3800_button_controller.py` + - The updated `__main__.py` and `config.py` wiring for `XVF3800ButtonController` +- The ReSpeaker XVF3800 is connected over USB and appears in `lsusb` as: + - `2886:001a Seeed Technology Co., Ltd. reSpeaker XVF3800 4-Mic Array` +- The LVA `.venv` includes `pyusb` (already present in `pyproject.toml`): + ```bash + source .venv/bin/activate + pip install pyusb + ``` + +On most systems, user‑mode processes can talk to the XVF3800 over USB without +`sudo` once the device is enumerated. If you see `USBError: [Errno 13] Access denied`, +you may need a udev rule — that’s outside this document but worth noting. + +--- + +## 3. Enabling the XVF3800 Mute Integration + +The integration is controlled via the `button` section of `config.json`. +Two “modes” are supported: + +- `"gpio"` – legacy Raspberry Pi GPIO button (ReSpeaker 2‑Mic HAT, etc.) +- `"xvf3800"` – USB‑based mute integration for the ReSpeaker XVF3800 + +### 3.1 Example `config.json` for XVF3800 + +Edit your LVA configuration file (usually `linux_voice_assistant/config.json`) +and add or adjust the `button` block like this: + +```jsonc +{ + "app": { + "name": "Linux Voice Assistant" + }, + + // ... other sections (audio, wake_word, esphome, led, mqtt, etc.) ... + + "button": { + "enabled": true, + "mode": "xvf3800", + // Optional: how often to poll the XVF3800 GPO state (seconds) + "poll_interval_seconds": 0.05 + } +} +``` + +Notes: + +- `"enabled": true` turns on button support in general. +- `"mode": "xvf3800"` switches from the GPIO‑based button controller to the + USB‑based XVF3800 controller. +- `poll_interval_seconds` is optional and defaults to `0.01` if omitted. Values + between 0.02 and 0.1 are typically fine. + +The GPIO‑specific fields (`pin`, `long_press_seconds`) are ignored in +`xvf3800` mode — the XVF3800’s onboard mute button is treated as a +**dedicated mute toggle only**, not a wake/stop button. + +--- + +## 4. Runtime Behaviour + +Once enabled and the LVA service is restarted, the mute integration behaves as follows: + +### 4.1 Pressing the XVF3800 Mute Button + +1. The XVF3800 firmware changes the GPO state of pin X0D30 and lights or clears + the red mute LED. +2. The `XVF3800ButtonController` sees the changed GPO values the next time it + polls USB. +3. It publishes a `set_mic_mute` event on the LVA `EventBus` with the new state. +4. The `MicMuteHandler`: + - Updates `ServerState.mic_muted` and `mic_muted_event` (pausing/resuming the + audio capture thread). + - Publishes `mic_muted` / `mic_unmuted` events used by other components + (LED controller, MQTT, etc.). + - Notifies the MQTT controller so the HA “Mute Microphone” entity stays in sync. + +From the user’s perspective: + +- The red LED on the XVF3800 tracks mic mute. +- The HA “Mute Microphone” switch also tracks the hardware button. +- The audio engine actually stops capturing when muted. + +### 4.2 Toggling Mute from Home Assistant / MQTT / Other Inputs + +1. Home Assistant toggles the LVA “Mute Microphone” switch (or another input + publishes a `set_mic_mute` event). +2. The `MicMuteHandler` applies the new mute state, as usual. +3. It emits a `mic_muted` or `mic_unmuted` event. +4. The `XVF3800ButtonController` receives this event and writes the new state + back to X0D30 via USB. +5. The XVF3800’s mic mute circuit and red LED update to match LVA. + +This keeps **hardware**, **LVA state**, and **Home Assistant UI** aligned. + +--- + +## 5. Coexistence with Other Buttons + +The XVF3800 mute integration is intentionally narrow in scope: + +- It does **not** implement short vs. long press behaviour. +- It does **not** trigger wake‑word, stop‑word, or playback controls. +- It is purely “mute on / mute off” with bidirectional sync. + +If you also have a GPIO button (e.g. a ReSpeaker 2‑Mic HAT momentary switch) +wired for wake/stop behaviour, you can: + +- Leave `button.mode: "gpio"` and **not** use the XVF3800 mute integration, or +- Move wake/stop to the GPIO button controller and leave the XVF3800 button + as mute‑only by setting `mode: "xvf3800"` and handling wake/stop elsewhere. + +At the moment, `button.mode` is **single‑valued** — it selects either the +GPIO controller or the XVF3800 controller, not both at once. If you ever want +to support both concurrently, that would be a separate enhancement (e.g. a +list of button backends). + +--- + +## 6. Troubleshooting + +### 6.1 No USB Access / Permission Errors + +Symptoms in the logs (journalctl or console): + +```text +Failed to initialize XVF3800 USB client; mute button integration will be disabled +USBError: [Errno 13] Access denied (insufficient permissions) +``` + +Check: + +- `lsusb` shows `2886:001a` while LVA is running. +- The LVA service is running as the user who owns the `.venv` and has + access to USB devices. +- If necessary, add a udev rule that grants access to `idVendor=0x2886` + and `idProduct=0x001a` for your LVA user, then reload udev and + replug the device. + +The controller will keep retrying USB initialization; once permissions are +fixed, it should connect without restarting LVA. + +### 6.2 Mute Button Works but HA Entity Doesn’t Update + +- Verify MQTT is enabled and configured correctly in `config.json`. +- Confirm that the HA “Mute Microphone” entity exists and belongs to the + correct LVA device. +- Check logs for `MicMuteHandler` and `MqttController` messages when pressing + the XVF3800 mute button. + +### 6.3 HA Mute Switch Works but XVF3800 LED Doesn’t Change + +- Ensure `button.enabled` is `true` and `"mode": "xvf3800"` in `config.json`. +- Look for log lines like: + - `Initializing XVF3800ButtonController (mode=xvf3800)` + - `Connected to ReSpeaker XVF3800 for mute control` +- If you see only GPIO logs, confirm that the branch you’re running includes + `xvf3800_button_controller.py` and the updated `__main__.py` wiring. + +--- + +## 7. Summary + +With `mode: "xvf3800"`, the Linux Voice Assistant treats the ReSpeaker +XVF3800’s onboard mute button and red LED as a **first‑class mute control**: + +- Pressing the XVF3800 mute button updates LVA and Home Assistant. +- Muting via Home Assistant or other inputs updates the XVF3800 hardware. +- The audio engine, LEDs, and MQTT entities stay in sync with the device’s + actual hardware mute state. + +This integration is fully optional and coexists with existing audio and LED +behaviour — it simply adds a reliable, hardware‑based mute path for the +XVF3800‑based satellites. From 13aad43c2909a768c0e751c257359f3ad24a159b Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:29:01 -0800 Subject: [PATCH 07/59] XVF3800 Mute Button Support Import XVF3800ButtonController. In _init_controllers, choose between ButtonController and XVF3800ButtonController based on config.button.mode. --- linux_voice_assistant/__main__.py | 37 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 6381589..20b723e 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -25,6 +25,7 @@ from .mpv_player import MpvMediaPlayer from .satellite import VoiceSatelliteProtocol from .util import get_mac_address, format_mac from .zeroconf import HomeAssistantZeroconf +from .xvf3800_button_controller import XVF3800ButtonController # NEW _LOGGER = logging.getLogger(__name__) _MODULE_DIR = Path(__file__).parent @@ -426,21 +427,33 @@ def _init_controllers( mqtt_controller=mqtt_controller, ) - # Hardware button controller (e.g. ReSpeaker 2-Mic HAT) + # Hardware / XVF3800 mute button controller 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) + button_cfg = getattr(config, "button", None) + if button_cfg is not None and button_cfg.enabled: + mode = getattr(button_cfg, "mode", "gpio").lower() + if mode == "xvf3800": + _LOGGER.info("Initializing XVF3800ButtonController (mode=xvf3800)") + xvf_btn = XVF3800ButtonController( + loop=loop, + event_bus=event_bus, + state=state, + button_config=button_cfg, + ) + setattr(state, "xvf3800_button_controller", xvf_btn) + else: + _LOGGER.info("Initializing GPIO ButtonController (mode=gpio)") + button_controller = ButtonController( + loop=loop, + event_bus=event_bus, + state=state, + config=button_cfg, + ) + setattr(state, "button_controller", button_controller) else: - _LOGGER.debug("ButtonController not enabled in config; skipping") + _LOGGER.debug("Button controller not enabled in config; skipping") except Exception: - _LOGGER.exception("Failed to initialize ButtonController") + _LOGGER.exception("Failed to initialize button controller(s)") async def _run_server(state: ServerState, config: Config): """Starts the ESPHome server and ZeroConf discovery.""" From a52b503cdb8db7be67c74c9fa69b0a3d8ab55845 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:31:18 -0800 Subject: [PATCH 08/59] XVF3800 Mute Button Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linux_voice_assistant/config.py – adds button.mode + poll_interval_seconds --- linux_voice_assistant/config.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py index ce5bf5c..cc5c7f7 100644 --- a/linux_voice_assistant/config.py +++ b/linux_voice_assistant/config.py @@ -44,7 +44,8 @@ class LedConfig: interface: str = "spi" clock_pin: int = 13 data_pin: int = 12 - num_leds: int = 3 # Note: overridden by 'preferences.json' if it exists + # Note: overridden by 'preferences.json' if it exists + num_leds: int = 3 @dataclass @@ -59,12 +60,27 @@ class MqttConfig: @dataclass class ButtonConfig: - """Settings for a hardware momentary button (e.g. ReSpeaker 2-Mic HAT).""" + """ + Settings for a hardware momentary button. + + mode: + - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT) + - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800 + + For mode="gpio": + - 'pin' and 'long_press_seconds' are used (short vs long press). + For mode="xvf3800": + - The XVF3800ButtonController uses the built-in mute button purely + as a mute toggle; 'pin' and 'long_press_seconds' are ignored. + """ enabled: bool = False - # BCM GPIO pin number for the button input. + mode: str = "gpio" # "gpio" or "xvf3800" + # BCM GPIO pin number for the button input (gpio mode only). pin: int = 17 - # Press duration (in seconds) to be considered a "long press". + # Press duration (in seconds) to be considered a "long press" (gpio mode). long_press_seconds: float = 1.0 + # Poll interval used by both GPIO and XVF3800 controllers. + poll_interval_seconds: float = 0.01 @dataclass From e845c5c811f07942fb1d2ac30de2ffe82b828e29 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:33:21 -0800 Subject: [PATCH 09/59] XVF3800 Mute Button Support This is the same design as earlier, but now explicitly expects button_config.mode/poll_interval_seconds to exist (with defaults from ButtonConfig). --- .../xvf3800_button_controller.py | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 linux_voice_assistant/xvf3800_button_controller.py diff --git a/linux_voice_assistant/xvf3800_button_controller.py b/linux_voice_assistant/xvf3800_button_controller.py new file mode 100644 index 0000000..dcb9187 --- /dev/null +++ b/linux_voice_assistant/xvf3800_button_controller.py @@ -0,0 +1,421 @@ +"""XVF3800 USB-based button & mute controller. + +This module integrates the ReSpeaker XVF3800's built-in mute/mic-control +with the Linux Voice Assistant (LVA): + +- Observes the XVF3800's GPO state to detect hardware mute changes. +- Publishes "set_mic_mute" events on the EventBus when the user presses + the physical mute button on the XVF3800. +- Listens for "mic_muted"/"mic_unmuted" events and mirrors the LVA's + mute state back to the XVF3800 via its USB control interface. + +The XVF3800's GPIO mapping (from Seeed docs) is: + + GPI (inputs): + X1D09 - Mute button state (high = released, low = pressed) + X1D13 - Floating + X1D34 - Floating + + GPO (outputs): + X0D11 - Floating + X0D30 - Mic mute + red mute LED (high = muted, LED on) + X0D31 - Amplifier enable (low = enabled) + X0D33 - WS2812 LED power enable + X0D39 - Floating + +We do NOT try to interpret short/long presses like the generic GPIO +ButtonController; the XVF3800 mute button is treated as "mute toggle only". +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import dataclass +from typing import List, Optional, TYPE_CHECKING + +import usb.core # type: ignore[import] +import usb.util # type: ignore[import] + +from .event_bus import EventBus, EventHandler, subscribe + +if TYPE_CHECKING: + from .models import ServerState + +_LOGGER = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Low-level USB client (minimal subset of the XMOS/Seeed control protocol) +# --------------------------------------------------------------------------- + + +class XVF3800USBClient: + """Minimal USB control client for the ReSpeaker XVF3800. + + This wraps the vendor-specific control transfers needed for: + - GPO_READ_VALUES (read mic mute LED / amp / LED power pins) + - GPO_WRITE_VALUE (set mic mute LED pin) + + It deliberately does NOT touch interfaces/altsettings so that audio + streaming over USB is unaffected. + """ + + VENDOR_ID = 0x2886 + PRODUCT_ID = 0x001A + TIMEOUT_MS = 1000 + + # From Seeed's documentation (GPO control): + # - Resid = 20 (GPO_SERVICER_RESID) + # - CmdId = 0 -> GPO_READ_VALUES (3/5 bytes depending on doc version) + # - CmdId = 1 -> GPO_WRITE_VALUE + # + # We expect GPO_READ_VALUES to return 5 bytes: + # [X0D11, X0D30, X0D31, X0D33, X0D39] + # + GPO_RESID = 20 + GPO_READ_CMDID = 0 + GPO_WRITE_CMDID = 1 + GPO_NUM_PINS = 5 + + # Index of the mic mute/red LED pin in the GPO_READ_VALUES response + # (X0D30, second element). + GPO_MUTE_INDEX = 1 + + def __init__(self) -> None: + dev = usb.core.find(idVendor=self.VENDOR_ID, idProduct=self.PRODUCT_ID) + if dev is None: + raise RuntimeError( + "ReSpeaker XVF3800 USB device not found " + f"(VID=0x{self.VENDOR_ID:04X}, PID=0x{self.PRODUCT_ID:04X})" + ) + + self._dev = dev + _LOGGER.debug( + "Initialized XVF3800USBClient (bus=%s, address=%s)", + getattr(dev, "bus", "?"), + getattr(dev, "address", "?"), + ) + + # Internal helpers ----------------------------------------------------- + + def _ctrl_read(self, resid: int, cmdid: int, length: int) -> List[int]: + """Perform a vendor-specific control IN transfer and return payload bytes.""" + # Per XMOS protocol: read cmdid is (0x80 | cmdid) + wValue = 0x80 | cmdid + wIndex = resid + + data = self._dev.ctrl_transfer( + usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE, + 0, + wValue, + wIndex, + length, + self.TIMEOUT_MS, + ) + if not data: + raise RuntimeError("Empty response from XVF3800 vendor control read") + + status = data[0] + # 0 = CONTROL_SUCCESS, 64 = SERVICER_COMMAND_RETRY in XMOS docs, + # but for our simple use case we just require success. + if status != 0: + raise RuntimeError(f"Unexpected XVF3800 control status: {status}") + + # Return the payload bytes as a Python list, excluding status byte. + return list(data[1:]) + + def _ctrl_write(self, resid: int, cmdid: int, payload: bytes) -> None: + """Perform a vendor-specific control OUT transfer.""" + wValue = cmdid + wIndex = resid + + # Note: ctrl_transfer will raise usb.core.USBError on failure. + self._dev.ctrl_transfer( + usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE, + 0, + wValue, + wIndex, + payload, + self.TIMEOUT_MS, + ) + + # Public API ----------------------------------------------------------- + + def read_gpo_values(self) -> List[int]: + """Read all GPO pin values. + + Returns a list of integers, one per pin: + [X0D11, X0D30, X0D31, X0D33, X0D39] + """ + length = self.GPO_NUM_PINS + 1 # +1 for status byte + values = self._ctrl_read(self.GPO_RESID, self.GPO_READ_CMDID, length) + if len(values) < self.GPO_NUM_PINS: + _LOGGER.warning( + "XVF3800 GPO_READ_VALUES returned %d bytes, expected >= %d", + len(values), + self.GPO_NUM_PINS, + ) + return values + + def get_mute_gpo(self) -> Optional[bool]: + """Return the current mic-mute output state from X0D30. + + Returns: + True -> mics muted / red mute LED on + False -> mics unmuted / red mute LED off + None -> could not read values (USB error) + """ + try: + values = self.read_gpo_values() + if len(values) <= self.GPO_MUTE_INDEX: + _LOGGER.error( + "XVF3800 GPO_READ_VALUES payload too short: %r", values + ) + return None + mute_val = values[self.GPO_MUTE_INDEX] + return bool(mute_val) + except usb.core.USBError as err: + _LOGGER.error("USBError reading XVF3800 GPO values: %s", err) + except Exception: + _LOGGER.exception("Unexpected error reading XVF3800 GPO values") + return None + + def set_mute_gpo(self, muted: bool) -> bool: + """Set the mic-mute output (X0D30) high/low. + + Args: + muted: True to mute mics + turn red LED on, False to unmute. + + Returns: + True on success, False on error. + """ + # Payload: [pin_index, value] as uint8 each + payload = bytes([30, 1 if muted else 0]) + try: + self._ctrl_write(self.GPO_RESID, self.GPO_WRITE_CMDID, payload) + return True + except usb.core.USBError as err: + _LOGGER.error("USBError writing XVF3800 GPO mute value: %s", err) + except Exception: + _LOGGER.exception("Unexpected error writing XVF3800 GPO mute value") + return False + + def close(self) -> None: + """Dispose of USB resources.""" + try: + usb.util.dispose_resources(self._dev) + except Exception: + # Not fatal; just log at debug level. + _LOGGER.debug("Error disposing XVF3800 USB resources", exc_info=True) + + +# --------------------------------------------------------------------------- +# High-level controller: bridge between XVF3800 and LVA mute state +# --------------------------------------------------------------------------- + + +@dataclass +class XVF3800ButtonRuntimeConfig: + """Runtime config for the XVF3800 button/mute controller.""" + + poll_interval_seconds: float = 0.05 # How often to poll GPO state + + +class XVF3800ButtonController(EventHandler): + """Monitor & synchronize the XVF3800's mute state with LVA. + + Responsibilities: + + - Poll X0D30 (GPO_READ_VALUES) on a background thread. + - When the hardware mute state changes (e.g. user presses the + XVF3800's mute button), publish "set_mic_mute" with the new + state so MicMuteHandler can update ServerState, MQTT, LEDs, etc. + - Listen for "mic_muted" and "mic_unmuted" events and mirror those + changes back to the XVF3800 by setting X0D30 via GPO_WRITE_VALUE. + + IMPORTANT: + - This controller does *not* implement short/long press semantics. + The XVF3800's onboard button is treated as a dedicated mute + toggle; wake/stop behavior still comes from wake words or a + separate GPIO button. + """ + + def __init__( + self, + loop, + event_bus: EventBus, + state: "ServerState", + button_config, + ) -> None: + super().__init__(event_bus) + self.loop = loop + self.state = state + + # Poll interval comes from the button config if present. + poll_interval = 0.05 + if hasattr(button_config, "poll_interval_seconds"): + try: + poll_interval = float(button_config.poll_interval_seconds) + except Exception: + _LOGGER.warning( + "Invalid poll_interval_seconds in button config; " + "defaulting to %.3fs", + poll_interval, + ) + + self._cfg = XVF3800ButtonRuntimeConfig( + poll_interval_seconds=poll_interval, + ) + + self._thread: Optional[threading.Thread] = None + self._shutdown_flag = threading.Event() + + # Last known hardware mute state (bool) or None if unknown. + self._last_hw_muted: Optional[bool] = None + + # Target mute state from LVA (set via mic_muted/mic_unmuted events). + self._target_mute_state_lock = threading.Lock() + self._target_mute_state: Optional[bool] = None + + # USB client is created lazily in the polling thread so that any + # USB errors don't break __init__. + self._usb_client: Optional[XVF3800USBClient] = None + + # Start polling thread immediately. + self._thread = threading.Thread( + target=self._poll_loop, + name="XVF3800ButtonControllerThread", + daemon=True, + ) + self._thread.start() + _LOGGER.info( + "XVF3800ButtonController started (poll_interval=%.3fs)", + self._cfg.poll_interval_seconds, + ) + + # Subscribe to events *after* initialization is complete. + self._subscribe_all_methods() + + # ------------------------------------------------------------------ + # Event handlers from EventBus + # ------------------------------------------------------------------ + + @subscribe + def mic_muted(self, data: dict) -> None: + """Mirror LVA mute -> XVF3800 hardware.""" + self._set_target_mute_state(True) + + @subscribe + def mic_unmuted(self, data: dict) -> None: + """Mirror LVA mute -> XVF3800 hardware.""" + self._set_target_mute_state(False) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def stop(self) -> None: + """Signal the polling thread to stop and wait for it.""" + self._shutdown_flag.set() + if self._thread is not None: + self._thread.join(timeout=2.0) + if self._usb_client is not None: + self._usb_client.close() + _LOGGER.info("XVF3800ButtonController stopped") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _set_target_mute_state(self, muted: bool) -> None: + """Request that the polling thread set the XVF3800 mute state.""" + with self._target_mute_state_lock: + self._target_mute_state = muted + + def _take_target_mute_state(self) -> Optional[bool]: + """Atomically fetch and clear the pending target mute state.""" + with self._target_mute_state_lock: + value = self._target_mute_state + self._target_mute_state = None + return value + + def _ensure_usb_client(self) -> Optional[XVF3800USBClient]: + """Lazy-initialize the USB client on the polling thread.""" + if self._usb_client is not None: + return self._usb_client + + try: + self._usb_client = XVF3800USBClient() + _LOGGER.info("Connected to ReSpeaker XVF3800 for mute control") + except Exception: + _LOGGER.exception( + "Failed to initialize XVF3800 USB client; " + "mute button integration will be disabled" + ) + self._usb_client = None + return self._usb_client + + # ------------------------------------------------------------------ + # Polling loop + # ------------------------------------------------------------------ + + def _poll_loop(self) -> None: + """Background loop to synchronize mute state with XVF3800.""" + _LOGGER.debug("XVF3800ButtonController polling thread started") + + while not self._shutdown_flag.is_set() and not getattr( + self.state, "shutdown", False + ): + client = self._ensure_usb_client() + if client is None: + # If we failed to init USB, wait a bit and retry + time.sleep(2.0) + continue + + # 1) Apply any pending target state from LVA -> hardware + target = self._take_target_mute_state() + if target is not None: + if self._last_hw_muted is None or target != self._last_hw_muted: + success = client.set_mute_gpo(target) + if success: + self._last_hw_muted = target + _LOGGER.debug( + "Set XVF3800 hardware mute state -> %s", target + ) + + # 2) Read current hardware GPO mute state + hw_muted = client.get_mute_gpo() + if hw_muted is not None: + if self._last_hw_muted is None: + # First observation; treat hardware as source of truth + self._last_hw_muted = hw_muted + _LOGGER.info( + "Initial XVF3800 hardware mute state: %s", hw_muted + ) + # Align LVA state with hardware on first read + self.loop.call_soon_threadsafe( + self.state.event_bus.publish, + "set_mic_mute", + {"state": hw_muted}, + ) + elif hw_muted != self._last_hw_muted: + # Hardware mute changed (button pressed) + _LOGGER.info( + "Detected XVF3800 mute state change from %s to %s; " + "publishing set_mic_mute event", + self._last_hw_muted, + hw_muted, + ) + self._last_hw_muted = hw_muted + self.loop.call_soon_threadsafe( + self.state.event_bus.publish, + "set_mic_mute", + {"state": hw_muted}, + ) + + time.sleep(self._cfg.poll_interval_seconds) + + _LOGGER.debug("XVF3800ButtonController polling thread exiting") + From 59d8071384777fcfb709f02d4e7435af4bec4504 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:34:22 -0800 Subject: [PATCH 10/59] Delete linux_voice_assistant/xvf3800_controller.py Duplicate file... --- linux_voice_assistant/xvf3800_controller.py | 320 -------------------- 1 file changed, 320 deletions(-) delete mode 100644 linux_voice_assistant/xvf3800_controller.py diff --git a/linux_voice_assistant/xvf3800_controller.py b/linux_voice_assistant/xvf3800_controller.py deleted file mode 100644 index da87b9e..0000000 --- a/linux_voice_assistant/xvf3800_controller.py +++ /dev/null @@ -1,320 +0,0 @@ -"""XVF3800 USB/HID controller scaffold. - -This module is intended to integrate the ReSpeaker XVF3800 mute button and -mute LED with the Linux Voice Assistant (LVA) mute state. - -Design goals: - - HW mute button -> LVA: - * When the user presses the XVF3800 mute button, the board mutes the - mic and lights the red LED. - * This controller detects the change over USB HID and publishes the - standard "set_mic_mute" event so the existing MicMuteHandler can: - - update ServerState.mic_muted - - update ServerState.mic_muted_event - - publish MQTT mute state - - notify the tray, etc. - - - LVA -> HW: - * When LVA toggles mute (via HA MQTT, ReSpeaker 2-Mic GPIO button, etc.), - this controller receives the same "set_mic_mute" event and sends a - USB HID command to synchronize the XVF3800's internal mute state and - red LED. - -This file is a scaffold: - - The USB/HID read/write paths are structured, but the actual report - format is not yet known. - - TODOs are marked where the mute bit must be decoded or set. - - Once the test script (xvf3800_hid_mute_probe.py) has revealed the - HID report layout, implement _parse_hw_mute_from_report() and - _send_hw_mute_state(). -""" - -from __future__ import annotations - -import logging -import threading -import time -from dataclasses import dataclass -from typing import Optional - -from .event_bus import EventBus, EventHandler, subscribe -from .models import ServerState - -_LOGGER = logging.getLogger(__name__) - -try: # pragma: no cover - depends on platform / install - import hid # type: ignore[import] -except Exception: # ImportError, RuntimeError, etc. - hid = None # type: ignore[assignment] - - -@dataclass -class Xvf3800RuntimeConfig: - """Runtime configuration for the XVF3800 controller. - - This is intentionally simple; if we later add more XVF-specific options, - they can be added here and wired from config.json. - """ # noqa: D401 - - enabled: bool = True - vendor_id: int = 0x2886 - product_id: int = 0x001A - read_timeout_ms: int = 200 - poll_interval_seconds: float = 0.02 - - -class Xvf3800Controller(EventHandler): - """Controller for the ReSpeaker XVF3800 USB/HID mute integration. - - Responsibilities: - - Open the XVF3800 HID interface (if available and enabled). - - Run a background thread that reads HID reports and detects mute - state changes, publishing "set_mic_mute" events back into the LVA. - - Listen for "set_mic_mute" events and push desired mute state down - to the XVF3800 (muting the mic and controlling the red LED). - - Safe failure: - - If the 'hid' module is not available, or no matching XVF3800 device - is found, the controller logs an info message and stays inactive. - - All errors in the polling thread are logged but do not crash LVA. - """ - - def __init__( - self, - loop, - event_bus: EventBus, - state: ServerState, - config: Optional[Xvf3800RuntimeConfig] = None, - ) -> None: - super().__init__(event_bus) - - self.loop = loop - self.state = state - self._cfg = config or Xvf3800RuntimeConfig() - - self._thread: Optional[threading.Thread] = None - self._hid_dev: Optional["hid.device"] = None # type: ignore[name-defined] - self._last_hw_mute_state: Optional[bool] = None - self._running = False - - if not self._cfg.enabled: - _LOGGER.info("Xvf3800Controller disabled in config; not initializing HID") - return - - if hid is None: - _LOGGER.info( - "hidapi not available; XVF3800 mute integration disabled. " - "Install it with 'pip install hidapi' if you want this feature.", - ) - return - - # Attempt to open the XVF3800 HID interface. - try: - self._hid_dev = self._open_device( - self._cfg.vendor_id, - self._cfg.product_id, - ) - except Exception: - _LOGGER.exception( - "Failed to open XVF3800 HID device (VID=0x%04x PID=0x%04x); " - "mute integration disabled", - self._cfg.vendor_id, - self._cfg.product_id, - ) - self._hid_dev = None - - if self._hid_dev is None: - return - - self._running = True - self._thread = threading.Thread( - target=self._poll_loop, - name="Xvf3800ControllerThread", - daemon=True, - ) - self._thread.start() - - # Subscribe to events AFTER init - self._subscribe_all_methods() - - _LOGGER.info( - "Xvf3800Controller initialized (VID=0x%04x PID=0x%04x)", - self._cfg.vendor_id, - self._cfg.product_id, - ) - - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ - - def stop(self) -> None: - """Stop the polling thread and close HID device (if any).""" - self._running = False - if self._thread is not None and self._thread.is_alive(): - self._thread.join(timeout=1.0) - if self._hid_dev is not None: - try: - self._hid_dev.close() - except Exception: - _LOGGER.exception("Error closing XVF3800 HID device") - _LOGGER.debug("Xvf3800Controller stopped") - - # ------------------------------------------------------------------ - # HID setup - # ------------------------------------------------------------------ - - def _open_device(self, vendor_id: int, product_id: int): - """Open the first HID interface that matches the XVF3800 VID/PID. - - If multiple HID interfaces are present, we currently pick the first - one returned by hid.enumerate(). If this proves unreliable, we can - refine selection using interface_number/product_string. - """ - devices = [ - dev - for dev in hid.enumerate() # type: ignore[attr-defined] - if dev.get("vendor_id") == vendor_id - and dev.get("product_id") == product_id - ] - - if not devices: - _LOGGER.warning( - "No HID interfaces found for XVF3800 (VID=0x%04x PID=0x%04x)", - vendor_id, - product_id, - ) - return None - - dev_info = devices[0] - path = dev_info.get("path") - _LOGGER.info( - "Opening XVF3800 HID device: path=%r serial=%r interface=%r", - path, - dev_info.get("serial_number"), - dev_info.get("interface_number"), - ) - - dev = hid.device() # type: ignore[call-arg] - if path is not None: - dev.open_path(path) - else: - dev.open(vendor_id, product_id) - - dev.set_nonblocking(True) - return dev - - # ------------------------------------------------------------------ - # Polling loop - # ------------------------------------------------------------------ - - def _poll_loop(self) -> None: - """Background loop reading HID reports and detecting mute changes.""" - _LOGGER.debug("Xvf3800Controller polling thread started") - - assert self._hid_dev is not None - - while self._running and not getattr(self.state, "shutdown", False): - try: - data = self._hid_dev.read( # type: ignore[union-attr] - 64, - timeout_ms=self._cfg.read_timeout_ms, - ) - except Exception: - _LOGGER.exception("Error reading from XVF3800 HID device") - break - - if data: - # 'data' is typically a list of ints; convert to bytes for parsing. - report = bytes(data) - new_state = self._parse_hw_mute_from_report(report) - if new_state is not None and new_state != self._last_hw_mute_state: - _LOGGER.debug( - "XVF3800 HW mute state changed: %s -> %s", - self._last_hw_mute_state, - new_state, - ) - self._last_hw_mute_state = new_state - - # Publish standard event; MicMuteHandler will take care - # of updating ServerState and emitting mic_muted/mic_unmuted. - self.loop.call_soon_threadsafe( - self.event_bus.publish, - "set_mic_mute", - {"state": new_state}, - ) - - time.sleep(self._cfg.poll_interval_seconds) - - _LOGGER.debug("Xvf3800Controller polling thread exiting") - - # ------------------------------------------------------------------ - # Parsing & writing HID reports (placeholders) - # ------------------------------------------------------------------ - - def _parse_hw_mute_from_report(self, report: bytes) -> Optional[bool]: - """Extract the current mute state from a raw HID report. - - Returns: - True -> HW reports 'muted' - False -> HW reports 'unmuted' - None -> This report does not contain mute information - - TODO: Implement this once we know the HID report layout. For now, - it logs the report at DEBUG level and returns None. - """ - _LOGGER.debug( - "XVF3800 HID report (len=%d): %s", - len(report), - report.hex(), - ) - # Once we have sample outputs from xvf3800_hid_mute_probe.py, - # implement the bit extraction here. - return None - - def _send_hw_mute_state(self, muted: bool) -> None: - """Send a HID command to set the XVF3800 mute state + LED. - - TODO: Once we understand the HID output/report format (from docs or - reverse engineering), construct and write the correct report. - For now, this method just logs what it *would* do. - """ - if self._hid_dev is None: - return - - _LOGGER.debug( - "(stub) Would send HID command to set XVF3800 mute=%s", - muted, - ) - # Example once we know the correct output report: - # report_id = 0x01 - # payload = bytes([report_id, 0x01 if muted else 0x00, 0x00, 0x00, ...]) - # try: - # self._hid_dev.write(payload) - # except Exception: - # _LOGGER.exception("Error writing XVF3800 HID mute command") - - # ------------------------------------------------------------------ - # EventBus subscriptions - # ------------------------------------------------------------------ - - @subscribe - def set_mic_mute(self, data: dict) -> None: - """Handle global 'set_mic_mute' events. - - This is the *same* event that MQTT, ButtonController, etc. use. By - listening here, we can sync the XVF3800 hardware state with LVA's - logical mute state. - - The central MicMuteHandler is still the source of truth; this - controller should not modify ServerState directly, only attempt to - mirror that state to the hardware. - """ - desired = bool(data.get("state", False)) - _LOGGER.debug( - "Xvf3800Controller received set_mic_mute event: desired=%s", - desired, - ) - - # Remember the last state so we don't treat an echo from hardware as new. - self._last_hw_mute_state = desired - self._send_hw_mute_state(desired) From b15848a97dd29c937414e960f97cb01d3a4dbf3a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:43:59 -0800 Subject: [PATCH 11/59] Add pyusb to required depends --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b1de8ed..b0bfbf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "RPi.GPIO", "gpiozero", "spidev", + "pyusb", ] [project.optional-dependencies] From 3d7cc4946614998feb3c153721b3705b41d6b439 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:06:45 -0800 Subject: [PATCH 12/59] Add support for XVF380 LEDs Keeps all existing DotStar/NeoPixel behavior intact. Adds a USB-based XVF3800 backend using the legacy LED controls (LED_EFFECT, LED_BRIGHTNESS, LED_SPEED, LED_COLOR). Plugs it into LedController by treating "led_type": "xvf3800", "interface": "usb" as a new backend. --- linux_voice_assistant/led_controller.py | 177 +++++++++++++++++++++--- 1 file changed, 159 insertions(+), 18 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index d9a1b17..0b936e1 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -13,7 +13,8 @@ except Exception: board = None # type: ignore[assignment] _LOGGER.warning( "Adafruit 'board' module not available or unsupported on this platform; " - "hardware LEDs will be disabled." + "DotStar/NeoPixel GPIO/SPI LED backends will be disabled. " + "XVF3800 USB LED backend is unaffected." ) from .config import LedConfig @@ -45,6 +46,12 @@ class LedController(EventHandler): self._is_ready = False self.leds = None + # Backend mode: + # "pixels" -> DotStar / NeoPixel via Adafruit drivers + # "xvf3800" -> XVF3800 USB LED ring backend + self._backend_mode: str = "pixels" + self._xvf3800_backend = None + # Configured LED behavior self.configs = { "idle": {"effect": "off", "color": _PURPLE, "brightness": 0.5}, @@ -56,29 +63,60 @@ class LedController(EventHandler): # Determine whether hardware LEDs should be used at all config_enabled = getattr(config, "enabled", True) - self._enabled = bool(config_enabled) and (board is not None) + + # For XVF3800 we do NOT require the Adafruit 'board' module. + if config.led_type == "xvf3800" and config.interface == "usb": + self._enabled = bool(config_enabled) + else: + self._enabled = bool(config_enabled) and (board is not None) if not config_enabled: _LOGGER.info( "LEDs disabled in config (led.enabled = false); " "LedController will run in no-op mode." ) - elif board is None: + elif not self._enabled: _LOGGER.warning( - "LED hardware libraries not available on this platform; " - "LedController will run in no-op mode." + "LED hardware libraries not available on this platform for led_type=%s; " + "LedController will run in no-op mode.", + config.led_type, ) # Always subscribe to events so MQTT + state logic remains intact, # even when LEDs are effectively disabled. self._subscribe_all_methods() - # If LEDs are not enabled or board is unavailable, skip hardware init + # If LEDs are not enabled at all, skip hardware init if not self._enabled: return # ------------------------------------------------------------------- - # Hardware initialization (Pi / supported boards only) + # XVF3800 USB LED backend + # ------------------------------------------------------------------- + if config.led_type == "xvf3800" and config.interface == "usb": + try: + from .xvf3800_led_backend import XVF3800LedBackend # type: ignore[import] + + self._xvf3800_backend = XVF3800LedBackend() + self._backend_mode = "xvf3800" + self._is_ready = True + _LOGGER.info( + "LED Controller initialized for XVF3800 USB LED ring (num_leds=%d).", + self.num_leds, + ) + self.run_action("startup_sequence") + except Exception: + # Any hardware-related failure leaves us in no-op mode + self._is_ready = False + self._enabled = False + self._xvf3800_backend = None + _LOGGER.exception( + "Failed to initialize XVF3800 LED backend. LEDs will be disabled." + ) + return + + # ------------------------------------------------------------------- + # Hardware initialization (DotStar / NeoPixel via Adafruit drivers) # ------------------------------------------------------------------- try: if config.led_type == "neopixel": @@ -128,6 +166,7 @@ class LedController(EventHandler): board.SCLK, board.MOSI, self.num_leds, auto_write=False ) + self._backend_mode = "pixels" self._is_ready = True _LOGGER.info( "LED Controller initialized for %d %s LEDs.", @@ -170,22 +209,100 @@ class LedController(EventHandler): config["state_name"] = state_name self.event_bus.publish("publish_state_to_mqtt", config) + # ----------------------------------------------------------------------- + # XVF3800-specific helpers + # ----------------------------------------------------------------------- + + async def _xvf3800_apply_effect( + self, + effect_name: str, + color: Tuple[int, int, int], + brightness: float, + ) -> None: + """Map generic effect/color/brightness to XVF3800 legacy LED controls.""" + if not (self._enabled and self._is_ready and self._xvf3800_backend): + return + + # Clamp brightness 0..1 and map to 0..255 + brightness = max(0.0, min(1.0, float(brightness))) + brightness_255 = int(brightness * 255) + + # Effect mapping to XVF3800 legacy modes + effect_map = { + "off": 0, # LED_EFFECT = off + "solid": 3, # single color + "slow_pulse": 1, # breath + "medium_pulse": 1, # breath + "fast_pulse": 1, # breath + "slow_blink": 1, # approximate with breath + "medium_blink": 1, # approximate with breath + "fast_blink": 1, # approximate with breath + "spin": 2, # rainbow as a stand-in for "spin" + } + + speed_map = { + "off": 0, + "solid": 0, + "slow_pulse": 0, + "medium_pulse": 1, + "fast_pulse": 2, + "slow_blink": 0, + "medium_blink": 1, + "fast_blink": 2, + "spin": 1, + } + + effect_id = effect_map.get(effect_name, 3) + speed_id = speed_map.get(effect_name, 1) + + r, g, b = color + try: + # Apply brightness, color, speed, and effect via USB control transfers. + self._xvf3800_backend.set_brightness(brightness_255) + if effect_name != "off": + self._xvf3800_backend.set_color(r, g, b) + self._xvf3800_backend.set_speed(speed_id) + self._xvf3800_backend.set_effect(effect_id) + except Exception: + _LOGGER.exception( + "Error sending LED effect '%s' to XVF3800 backend", effect_name + ) + + # Keep coroutine alive until cancelled so that a new effect + # can cancel the previous one consistently. + try: + while True: + await asyncio.sleep(3600) + except asyncio.CancelledError: + return + # ----------------------------------------------------------------------- # LED effect coroutines # ----------------------------------------------------------------------- async def startup_sequence(self, color=None, brightness=None): + # Use a simple green blink on startup for all backends. await self.blink(_GREEN, 1.0) async def off(self, color, brightness): if not (self._enabled and self._is_ready): return + + if self._backend_mode == "xvf3800": + await self._xvf3800_apply_effect("off", _OFF, 0.0) + return + self.leds.fill(_OFF) self.leds.show() async def solid(self, color: Tuple[int, int, int], brightness: float): if not (self._enabled and self._is_ready): return + + if self._backend_mode == "xvf3800": + await self._xvf3800_apply_effect("solid", color, brightness) + return + r, g, b = color self.leds.fill( (int(r * brightness), int(g * brightness), int(b * brightness)) @@ -196,10 +313,19 @@ class LedController(EventHandler): await self.medium_blink(color, brightness) async def _base_pulse( - self, color: Tuple[int, int, int], brightness: float, speed: float + self, + effect_name: str, + color: Tuple[int, int, int], + brightness: float, + speed: float, ): if not (self._enabled and self._is_ready): return + + if self._backend_mode == "xvf3800": + await self._xvf3800_apply_effect(effect_name, color, brightness) + return + try: r, g, b = color while True: @@ -218,24 +344,33 @@ class LedController(EventHandler): self.leds.show() await asyncio.sleep(speed) except asyncio.CancelledError: - if self._enabled and self._is_ready: + if self._enabled and self._is_ready and self.leds is not None: self.leds.fill(_OFF) self.leds.show() async def slow_pulse(self, color, brightness): - await self._base_pulse(color, brightness, 0.05) + await self._base_pulse("slow_pulse", color, brightness, 0.05) async def medium_pulse(self, color, brightness): - await self._base_pulse(color, brightness, 0.02) + await self._base_pulse("medium_pulse", color, brightness, 0.02) async def fast_pulse(self, color, brightness): - await self._base_pulse(color, brightness, 0.008) + await self._base_pulse("fast_pulse", color, brightness, 0.008) async def _base_blink( - self, color: Tuple[int, int, int], brightness: float, speed: float + self, + effect_name: str, + color: Tuple[int, int, int], + brightness: float, + speed: float, ): if not (self._enabled and self._is_ready): return + + if self._backend_mode == "xvf3800": + await self._xvf3800_apply_effect(effect_name, color, brightness) + return + try: r, g, b = color bright_color = ( @@ -251,24 +386,29 @@ class LedController(EventHandler): self.leds.show() await asyncio.sleep(speed) except asyncio.CancelledError: - if self._enabled and self._is_ready: + if self._enabled and self._is_ready and self.leds is not None: self.leds.fill(_OFF) self.leds.show() async def slow_blink(self, color, brightness): - await self._base_blink(color, brightness, 1.0) + await self._base_blink("slow_blink", color, brightness, 1.0) async def medium_blink(self, color, brightness): - await self._base_blink(color, brightness, 0.5) + await self._base_blink("medium_blink", color, brightness, 0.5) async def fast_blink(self, color, brightness): - await self._base_blink(color, brightness, 0.1) + await self._base_blink("fast_blink", color, brightness, 0.1) async def spin( self, color: Tuple[int, int, int], brightness: float, speed: float = 0.1 ): if not (self._enabled and self._is_ready): return + + if self._backend_mode == "xvf3800": + await self._xvf3800_apply_effect("spin", color, brightness) + return + try: i = 0 bright_color = ( @@ -283,7 +423,7 @@ class LedController(EventHandler): i += 1 await asyncio.sleep(speed) except asyncio.CancelledError: - if self._enabled and self._is_ready: + if self._enabled and self._is_ready and self.leds is not None: self.leds.fill(_OFF) self.leds.show() @@ -313,6 +453,7 @@ class LedController(EventHandler): @subscribe def mic_muted(self, data: dict): + # When muted, show a solid dim red on all backends. self.run_action("solid", _DIM_RED, 1.0) @subscribe From ae8f1c5c3ab004bb69463aa531ce911c5517c02d Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:08:01 -0800 Subject: [PATCH 13/59] Add legacy XVF3800 LED Controls Minimal pyusb-based wrapper focused on the legacy LED controls only --- linux_voice_assistant/xvf3800_led_backend.py | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 linux_voice_assistant/xvf3800_led_backend.py diff --git a/linux_voice_assistant/xvf3800_led_backend.py b/linux_voice_assistant/xvf3800_led_backend.py new file mode 100644 index 0000000..fb3d3cd --- /dev/null +++ b/linux_voice_assistant/xvf3800_led_backend.py @@ -0,0 +1,142 @@ +import logging +import struct +from typing import Optional, Sequence + +import usb.core +import usb.util + +_LOGGER = logging.getLogger(__name__) + +CONTROL_SUCCESS = 0 +SERVICER_COMMAND_RETRY = 64 + +# name, resid, cmdid, length, access, type +PARAMETERS = { + # GPO_SERVICER_RESID LED controls (legacy interface) + "LED_EFFECT": (20, 12, 1, "rw", "uint8"), + "LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8"), + "LED_GAMMIFY": (20, 14, 1, "rw", "uint8"), + "LED_SPEED": (20, 15, 1, "rw", "uint8"), + "LED_COLOR": (20, 16, 1, "rw", "uint32"), +} + + +class _ReSpeaker: + """Low-level USB control wrapper for XVF3800 parameters we care about.""" + + TIMEOUT = 100_000 + + def __init__(self, dev: usb.core.Device) -> None: # type: ignore[name-defined] + self.dev = dev + + def write(self, name: str, data_list: Sequence[int]) -> None: + try: + data = PARAMETERS[name] + except KeyError as exc: + raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc + + resid, cmdid, count, access, data_type = data + + if access == "ro": + raise ValueError(f"{name} is read-only") + + if len(data_list) != count: + raise ValueError(f"{name} expects {count} values, got {len(data_list)}") + + payload = bytearray() + + if data_type == "uint8": + for v in data_list: + payload += int(v).to_bytes(1, byteorder="little", signed=False) + elif data_type in ("uint32", "int32"): + fmt = b"I" if data_type == "uint32" else b"i" + for v in data_list: + payload += struct.pack(fmt, int(v)) + else: + # We don't actually use other types for LED controls. + raise ValueError(f"Unsupported data type '{data_type}' for {name}") + + _LOGGER.debug( + "XVF3800 write: name=%s resid=%s cmdid=%s payload=%s", + name, + resid, + cmdid, + list(payload), + ) + + self.dev.ctrl_transfer( + usb.util.CTRL_OUT + | usb.util.CTRL_TYPE_VENDOR + | usb.util.CTRL_RECIPIENT_DEVICE, + 0, + cmdid, + resid, + payload, + self.TIMEOUT, + ) + + def close(self) -> None: + usb.util.dispose_resources(self.dev) + + +def _find_device(vid: int = 0x2886, pid: int = 0x001A) -> Optional[_ReSpeaker]: + dev = usb.core.find(idVendor=vid, idProduct=pid) + if not dev: + return None + return _ReSpeaker(dev) + + +class XVF3800LedBackend: + """High-level LED backend for XVF3800 USB LED ring. + + This is intentionally minimal and only covers the legacy LED controls: + - LED_EFFECT + - LED_BRIGHTNESS + - LED_SPEED + - LED_COLOR + """ + + def __init__(self, vid: int = 0x2886, pid: int = 0x001A) -> None: + wrapper = _find_device(vid, pid) + if wrapper is None: + raise RuntimeError( + f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})" + ) + + self._dev = wrapper + _LOGGER.info( + "Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x)", vid, pid + ) + + # --------------------------------------------------------------------- + # Public high-level controls + # --------------------------------------------------------------------- + + def set_effect(self, effect_id: int) -> None: + """Set LED effect mode (0=off, 1=breath, 2=rainbow, 3=single color, 4=doa).""" + effect_id = int(effect_id) & 0xFF + self._dev.write("LED_EFFECT", [effect_id]) + + def set_brightness(self, brightness_0_255: int) -> None: + """Set LED brightness (0-255).""" + value = max(0, min(255, int(brightness_0_255))) + self._dev.write("LED_BRIGHTNESS", [value]) + + def set_speed(self, speed_id: int) -> None: + """Set LED effect speed (0=slow, 1=medium, 2=fast).""" + speed_id = int(speed_id) & 0xFF + self._dev.write("LED_SPEED", [speed_id]) + + def set_color(self, r: int, g: int, b: int) -> None: + """Set LED color for breath / single color modes (0xRRGGBB).""" + r = max(0, min(255, int(r))) + g = max(0, min(255, int(g))) + b = max(0, min(255, int(b))) + + color_value = (r << 16) | (g << 8) | b + self._dev.write("LED_COLOR", [color_value]) + + def close(self) -> None: + if self._dev is not None: + self._dev.close() + From 957d3f28396a60305dc32ce76ee9e061341f4310 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:21:49 -0800 Subject: [PATCH 14/59] Update to include XVF3800 Support --- linux_voice_assistant/config.json.example | 173 +++++++++++++++------- 1 file changed, 122 insertions(+), 51 deletions(-) diff --git a/linux_voice_assistant/config.json.example b/linux_voice_assistant/config.json.example index 2d5bcea..6f64071 100644 --- a/linux_voice_assistant/config.json.example +++ b/linux_voice_assistant/config.json.example @@ -1,62 +1,133 @@ { "app": { - "name": "Linux Voice Assistant", - "debug": true, - "wakeup_sound": "sounds/wake_word_triggered.flac", - "timer_finished_sound": "sounds/timer_finished.flac", - "preferences_file": "preferences.json" + // Human-readable name; also used to derive the MQTT device_id + // Example: "Bedroom Voice Assistant" -> device_id = "bedroom_voice_assistant" + "name": "Linux Voice Assistant" }, - "audio": { - // This is the "manual bypass" for audio. - // Set to "null" to use auto-detection (recommended). - // Set to specific device names to force them. - "input_device": "seeed-2mic-voicecard", - "output_device": "alsa/plughw:CARD=seeed2micvoicec", - "input_block_size": 1024 - }, - "wake_word": { - // A list of directories to scan for wake word models - "directories": [ - "wakewords", - "wakewords/openWakeWord" - ], - // The default wake word to use if none is set in preferences - "model": "okay_nabu", - "stop_model": "stop", - "refractory_seconds": 2.0, - // Paths are relative to the project root - "oww_melspectrogram_model": "wakewords/openWakeWord/melspectrogram.tflite", - "oww_embedding_model": "wakewords/openWakeWord/embedding_model.tflite" - }, - "esphome": { - // Network settings for the satellite server - "host": "0.0.0.0", - "port": 6053 - }, - "led": { - // "led_type" can be "dotstar" (APA102) or "neopixel" (WS2812B) - "led_type": "dotstar", - // "interface" can be "spi" (hardware SPI) or "gpio" (bit-banging) - "interface": "gpio", - - // --- GPIO Settings --- - // These are ONLY used if interface is "gpio" - // For DotStar on Grove port: clock=13, data=12 - // For NeoPixel on Grove port: data=12 (clock not used and is ignored) - "clock_pin": 13, - "data_pin": 12, - - // Default number of LEDs. This is overridden by - // "num_leds" in preferences.json after first run. - "num_leds": 3 - }, "mqtt": { - // "host" is the only required field to enable MQTT. - // If "host" is null or this section is omitted, MQTT is disabled. + // Standard MQTT settings (optional if you don't use MQTT/HA discovery) "host": "192.168.1.2", "port": 1883, "username": "mqtt_username", "password": "mqtt_password" + }, + + // --------------------------------------------------------------------------- + // LED CONFIGURATION + // --------------------------------------------------------------------------- + // + // The LVA has a single logical LED backend that reflects the internal + // voice states (idle/listening/thinking/responding/error) and mic mute. + // + // "led_type" selects WHICH hardware is used: + // - "dotstar" = external APA102/DotStar strip + // - "neopixel" = external WS281x/NeoPixel strip + // - "xvf3800" = use the XVF3800 built-in LED ring via USB control + // + // "interface" is only meaningful for dotstar/neopixel: + // - "spi" = hardware SPI (e.g. for APA102 strips) + // - "gpio" = bit-banged output on GPIO pins + // + // For "xvf3800", LEDs are always controlled over USB; "interface", + // "clock_pin", "data_pin", and "num_leds" are ignored by the driver. + // + "led": { + // ------------------------------------------------------------------------- + // Example 1: XVF3800 built-in LED ring + // ------------------------------------------------------------------------- + // Use the XVF3800 ring as the LVA's state indicator (recommended when + // the device is present). The driver talks to the XVF3800 over USB + // using the XMOS control protocol (LED_EFFECT, LED_COLOR, etc.). + // + "led_type": "xvf3800", + + // For xvf3800, the interface is always USB internally. + // This field is kept for schema compatibility but is ignored. + "interface": "gpio", + + // Ignored when led_type == "xvf3800" + "clock_pin": 13, + "data_pin": 12, + + // Ignored when led_type == "xvf3800" (ring length is fixed in hardware). + // The MQTT "Number of LEDs" entity will not affect the XVF3800 ring. + "num_leds": 10 + + // ------------------------------------------------------------------------- + // Example 2: (commented out) DotStar on Grove port + // ------------------------------------------------------------------------- + /* + "led_type": "dotstar", + "interface": "gpio", + + // For ReSpeaker 2-Mic HAT Grove port: + // Yellow wire -> GPIO12 (data) + // White wire -> GPIO13 (clock) + "clock_pin": 13, + "data_pin": 12, + + // Default number of LEDs for a DotStar strip. + // This is overridden by preferences.json after first run. + "num_leds": 16 + */ + + // ------------------------------------------------------------------------- + // Example 3: (commented out) NeoPixel on Grove port + // ------------------------------------------------------------------------- + /* + "led_type": "neopixel", + "interface": "gpio", + + // For NeoPixel, only data_pin is used. + // Grove yellow -> GPIO12 (data) + "clock_pin": 13, // Ignored for NeoPixel + "data_pin": 12, + + "num_leds": 16 + */ + }, + "button": { + // "mode" can be: + // - "gpio" : ReSpeaker 2-Mic HAT or generic Pi GPIO button + // - "xvf3800" : Onboard mute button of the ReSpeaker XVF3800 + "mode": "gpio", + + // GPIO button only (ignored for xvf3800): + "enabled": true, + "pin": 17, + "long_press_seconds": 1.0, + + // XVF3800 mode has no extra options (for now). + // It always acts as a "mute toggle" only. + }, + + // --------------------------------------------------------------------------- + // AUDIO CONFIGURATION + // --------------------------------------------------------------------------- + // + // The XVF3800 presents itself as a standard USB audio device: + // - Input: "reSpeaker XVF3800 4-Mic Array Analog Stereo" + // - Output: "reSpeaker XVF3800 4-Mic Array Analog Stereo" + // + // With PipeWire or PulseAudio, these names show up in: + // script/run --list-input-devices + // script/run --list-output-devices + // + "audio": { + // Input device name (or leave omitted/empty to use the system default). + "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", + + // Default block size for the wake-word + streaming pipeline. + "input_block_size": 1024, + + // Output device used for TTS and media playback. + // For XVF3800 you can: + // - Use its own speaker output, or + // - Use HDMI/other sinks instead. + "output_device": "pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441253700076-00.analog-stereo" } + + // You can also include other sections (zeroconf, additional settings, etc.) + // as needed by the rest of the LVA configuration model. } From 70079d6a61d3af84bd633cf1732e0059b8de005f Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:57:02 -0800 Subject: [PATCH 15/59] Update config.py Bug fix. --- linux_voice_assistant/config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py index cc5c7f7..cde815c 100644 --- a/linux_voice_assistant/config.py +++ b/linux_voice_assistant/config.py @@ -39,7 +39,16 @@ class ESPHomeConfig: @dataclass class LedConfig: - """Settings for the LED strip.""" + """Settings for the LED strip. + + Fields: + enabled -> master on/off for hardware LEDs. If False, LedController + still subscribes to events and publishes MQTT state, but + never touches hardware. + led_type -> "dotstar", "neopixel", or "xvf3800" + interface -> "spi", "gpio", "usb", or "i2c" (future) + """ + enabled: bool = True led_type: str = "dotstar" interface: str = "spi" clock_pin: int = 13 From f1d2700fac5c6e0bfcc6f96b85e44f9265c4f0f1 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:43:04 -0800 Subject: [PATCH 16/59] LED Effect Mapping Background on LED effect mapping from XVF3800 to LVA LED effects. --- docs/xvf3800_legacy_led_effects_mapping.md | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/xvf3800_legacy_led_effects_mapping.md diff --git a/docs/xvf3800_legacy_led_effects_mapping.md b/docs/xvf3800_legacy_led_effects_mapping.md new file mode 100644 index 0000000..21e6b1e --- /dev/null +++ b/docs/xvf3800_legacy_led_effects_mapping.md @@ -0,0 +1,45 @@ +# XVF3800 Legacy LED Effects Mapping for Linux Voice Assistant + +This document maps the **legacy ReSpeaker XVF3800 LED_EFFECT modes** to the higher-level LED effects used by the **linux-voice-assistant (LVA)**. + +## XVF3800 Legacy LED_EFFECT Values + +From the Seeed/XMOS documentation: + +- `LED_EFFECT 0` → **off** +- `LED_EFFECT 1` → **breath** +- `LED_EFFECT 2` → **rainbow** +- `LED_EFFECT 3` → **single color** +- `LED_EFFECT 4` → **DoA mode** + +## LVA Effects → XVF3800 Mapping + +LVA exposes higher-level effects such as **Off / Solid / Slow/Medium/Fast Pulse / Slow/Medium/Fast Blink / Spin**. +On XVF3800 (legacy firmware), these are currently mapped onto the limited built-in modes as follows: + +| LVA Effect Name | XVF3800 `LED_EFFECT` | Additional Parameters | Behavior on XVF3800 (Legacy) | +|------------------|----------------------|--------------------------------------|-------------------------------------------------------------------------| +| `off` | `0` (off) | – | LED ring completely off. | +| `solid` | `3` (single color) | `LED_COLOR`, `LED_BRIGHTNESS` | Full ring in a single static color. | +| `slow_pulse` | `1` (breath) | `LED_SPEED` = slow | Slow breathing in the configured color. | +| `medium_pulse` | `1` (breath) | `LED_SPEED` = medium | Medium breathing in the configured color. | +| `fast_pulse` | `1` (breath) | `LED_SPEED` = fast | Fast breathing in the configured color. | +| `slow_blink` | `1` (breath) * | `LED_SPEED` = slow | Approximated with slow breath (no true sharp on/off blink). | +| `medium_blink` | `1` (breath) * | `LED_SPEED` = medium | Approximated with medium breath. | +| `fast_blink` | `1` (breath) * | `LED_SPEED` = fast | Approximated with fast breath. | +| `spin` | `3` or `1` ** | Typically treated as solid/pulsed | No native “spin”; currently falls back to a static or pulsed behavior. | + +\* There is no dedicated “blink” primitive in the legacy API, so all blink variants are currently rendered as **breathing** with different speeds. They will look like fading rather than a true square‑wave blink until we add a more advanced mapping or per‑LED pattern logic. + +\** The legacy firmware does not offer a true “spin” animation. For now, `spin` is effectively treated as either **single color** or a **breath** variant to keep behavior predictable. + +## Practical Notes + +- Under the legacy backend, **only three real modes** are used: + - `0` → off + - `1` → breath (with speed control) + - `3` → single color +- All the LVA **pulse** and **blink** variants are essentially **different speeds of `LED_EFFECT=1`**. +- The **spin** effect is not yet a genuine rotating animation on XVF3800; it’s a placeholder mapped onto existing firmware modes. + +As we move toward **firmware v2.0.7+** and `LED_RING_COLOR` support, we’ll be able to implement richer, per‑LED patterns (including real spin and more accurate blink behaviors) while keeping the same high-level LVA effect names. From 1cd861454ac939f5dca843bf282d3e0997176a63 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:09:07 -0800 Subject: [PATCH 17/59] Add files via upload --- docs/xvf3800_usb_led_testing.md | 134 ++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/xvf3800_usb_led_testing.md diff --git a/docs/xvf3800_usb_led_testing.md b/docs/xvf3800_usb_led_testing.md new file mode 100644 index 0000000..d68ccda --- /dev/null +++ b/docs/xvf3800_usb_led_testing.md @@ -0,0 +1,134 @@ +# Call for Testing: XVF3800 USB LED Support in `linux-voice-assistant` + +The XVF3800 branch now includes **USB-based LED ring support** using the XVF3800’s legacy LED control commands (`LED_EFFECT`, `LED_BRIGHTNESS`, `LED_SPEED`, `LED_COLOR`). This post outlines how to pull the branch, refresh your virtualenv, and enable XVF3800 support in `config.json`. + +--- + +## 1. Get the XVF3800 Branch + +If you **already have** the repo: + +```bash +cd ~/linux-voice-assistant +git fetch origin +git switch xvf3800 # or: git checkout xvf3800 +git pull +``` + +If you’re **cloning fresh**: + +```bash +cd ~ +git clone https://github.com/imonlinux/linux-voice-assistant.git +cd linux-voice-assistant +git switch xvf3800 # or: git checkout xvf3800 +``` + +--- + +## 2. Refresh the Virtualenv (New Dependencies) + +The XVF3800 backend uses **pyusb** and system libusb. + +From the project root: + +```bash +cd ~/linux-voice-assistant + +# Create venv if you don't already have one +python3 -m venv .venv + +# Activate it +source .venv/bin/activate + +# Upgrade pip and reinstall project deps (including pyusb) +pip install --upgrade pip +pip install -e . +``` + +On Debian/RPi OS, make sure libusb is installed: + +```bash +sudo apt update +sudo apt install libusb-1.0-0 +``` + +No extra `hidapi` is required for the LED integration (this path is pure pyusb). + +--- + +## 3. Enable XVF3800 Support in `config.json` + +Edit `linux_voice_assistant/config.json` and set the `led` block to use the XVF3800 USB backend. + +Minimal example: + +```jsonc +{ + "app": { + "name": "Linux Voice Assistant XVF3800" + }, + + "audio": { + // Set this to your XVF3800 input device name or index if needed + // "input_device": "reSpeaker XVF3800 4-Mic Array", + "input_block_size": 1024 + }, + + "led": { + // New: XVF3800 USB LED backend + // led_type can be "dotstar", "neopixel" or "xvf3800" + "led_type": "xvf3800", + + // For XVF3800 we currently support only "usb" + "interface": "usb", + + // Used for internal logic / MQTT, not hardware-driven on XVF3800 + "num_leds": 12, + + // Optional (defaults to true if omitted) + "enabled": true + }, + + // If you previously used the 2-Mic HAT button, disable it here + "button": { + "enabled": false + }, + + "mqtt": { + "host": "192.168.1.2", + "port": 1883, + "username": "mqtt_username", + "password": "mqtt_password" + } +} +``` + +> ✅ For non-XVF3800 setups, keep using `led_type: "dotstar"` / `"neopixel"` and your existing `interface` (`spi` or `gpio`). + +--- + +## 4. Run the Assistant with XVF3800 LEDs + +From the project root: + +```bash +cd ~/linux-voice-assistant +source .venv/bin/activate +script/run --debug +``` + +With the XVF3800 branch + config above, the **USB LED ring** should now: + +- Show the configured **idle / listening / thinking / responding / error** effects +- Turn **solid dim red** when the mic is muted +- Track changes coming from the MQTT LED state entities + +Please report back: + +- Which distro / Pi model you’re using +- Firmware version on the XVF3800 (if known) +- What you see on the LED ring for each LVA state + (idle, listening, thinking, responding, error, muted) + +This will help tighten the mappings and prepare for a follow-up pass that uses the newer `LED_RING_COLOR` capabilities. From 31b6d70910373572b3c1c29e14dc7dfa61d061aa Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:54:13 -0800 Subject: [PATCH 18/59] Update xvf3800_usb_led_testing.md Added the requirement for a udev rule. --- docs/xvf3800_usb_led_testing.md | 126 +++++++++++++++++++------------- 1 file changed, 75 insertions(+), 51 deletions(-) diff --git a/docs/xvf3800_usb_led_testing.md b/docs/xvf3800_usb_led_testing.md index d68ccda..86ba69a 100644 --- a/docs/xvf3800_usb_led_testing.md +++ b/docs/xvf3800_usb_led_testing.md @@ -1,6 +1,6 @@ -# Call for Testing: XVF3800 USB LED Support in `linux-voice-assistant` +# XVF3800 USB LED and Mute Button Support in `linux-voice-assistant` -The XVF3800 branch now includes **USB-based LED ring support** using the XVF3800’s legacy LED control commands (`LED_EFFECT`, `LED_BRIGHTNESS`, `LED_SPEED`, `LED_COLOR`). This post outlines how to pull the branch, refresh your virtualenv, and enable XVF3800 support in `config.json`. +The XVF3800 branch now includes **USB-based LED ring support** using the XVF3800’s legacy LED control commands (`LED_EFFECT`, `LED_BRIGHTNESS`, `LED_SPEED`, `LED_COLOR`). This post outlines how to pull the branch, refresh your virtualenv, and enable XVF3800 support in `config.json`. I fully expect bugs and you should too. Let me know if it works first try and I will by some lottery tickets as well. --- @@ -11,7 +11,7 @@ If you **already have** the repo: ```bash cd ~/linux-voice-assistant git fetch origin -git switch xvf3800 # or: git checkout xvf3800 +git switch XVF3800 # or: git checkout xvf3800 git pull ``` @@ -19,14 +19,12 @@ If you’re **cloning fresh**: ```bash cd ~ -git clone https://github.com/imonlinux/linux-voice-assistant.git -cd linux-voice-assistant -git switch xvf3800 # or: git checkout xvf3800 +git clone --branch XVF3800 --single-branch https://github.com/imonlinux/linux-voice-assistant.git ``` --- -## 2. Refresh the Virtualenv (New Dependencies) +## 2. Install/Refresh the Virtualenv (New Dependencies) The XVF3800 backend uses **pyusb** and system libusb. @@ -35,19 +33,15 @@ From the project root: ```bash cd ~/linux-voice-assistant +# remove the linux_voice_assistant.egg-info folder if it exists +rm -rf linux_voice_assistant.egg-info + # Create venv if you don't already have one -python3 -m venv .venv - -# Activate it -source .venv/bin/activate - -# Upgrade pip and reinstall project deps (including pyusb) -pip install --upgrade pip -pip install -e . +script/setup ``` - On Debian/RPi OS, make sure libusb is installed: + ```bash sudo apt update sudo apt install libusb-1.0-0 @@ -55,11 +49,56 @@ sudo apt install libusb-1.0-0 No extra `hidapi` is required for the LED integration (this path is pure pyusb). ---- +## 3. Copy the udev rule for the XVF3800 -## 3. Enable XVF3800 Support in `config.json` +On the Pi: -Edit `linux_voice_assistant/config.json` and set the `led` block to use the XVF3800 USB backend. +```bash +sudo cp ~linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/99-respeaker-xvf3800.rules +``` + +This does: + +- Matches the XVF3800 by VID/PID (2886:001a) + +- Sets permissions to 0660 + +- Assigns it to the plugdev group + +- Tags it for desktop access (uaccess) + +### Add user (pi) to the plugdev group +**use the user name that you have created if not pi** + +```bash +sudo usermod -aG plugdev pi +``` + +Then log out and back in (or reboot) so the new group membership takes effect. + +### Reload udev rules and replug the device + +If you didn't reboot, reload the udev rules: + +```bash +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +Then unplug/replug the XVF3800 USB cable. + +You can sanity-check permissions with: + +```bash +ls -l /dev/bus/usb/*/* +``` + +Look for the device entry that corresponds to 2886:001a (you can pair it with lsusb), and confirm it’s root plugdev with crw-rw----. + + +## 4. Enable XVF3800 Support in `config.json` + +Edit `linux_voice_assistant/config.json` and set the `led` block to use the XVF3800 USB backend. If you want to test the mute button, add the following button section as well. See the `linux_voice_assistant/config.json.example` file for additional options. Minimal example: @@ -68,33 +107,15 @@ Minimal example: "app": { "name": "Linux Voice Assistant XVF3800" }, - - "audio": { - // Set this to your XVF3800 input device name or index if needed - // "input_device": "reSpeaker XVF3800 4-Mic Array", - "input_block_size": 1024 - }, - "led": { - // New: XVF3800 USB LED backend - // led_type can be "dotstar", "neopixel" or "xvf3800" + "enabled": true, "led_type": "xvf3800", - - // For XVF3800 we currently support only "usb" - "interface": "usb", - - // Used for internal logic / MQTT, not hardware-driven on XVF3800 - "num_leds": 12, - - // Optional (defaults to true if omitted) - "enabled": true + "interface": "usb" }, - - // If you previously used the 2-Mic HAT button, disable it here "button": { - "enabled": false + "enabled": true, + "mode": "xvf3800" }, - "mqtt": { "host": "192.168.1.2", "port": 1883, @@ -104,17 +125,21 @@ Minimal example: } ``` -> ✅ For non-XVF3800 setups, keep using `led_type: "dotstar"` / `"neopixel"` and your existing `interface` (`spi` or `gpio`). - --- -## 4. Run the Assistant with XVF3800 LEDs +## 5. Run the Assistant with XVF3800 LEDs + +Stop any running systemd units: + +```bash +systemctl --user stop linux-voice-assistant.service +``` + From the project root: ```bash cd ~/linux-voice-assistant -source .venv/bin/activate script/run --debug ``` @@ -124,11 +149,10 @@ With the XVF3800 branch + config above, the **USB LED ring** should now: - Turn **solid dim red** when the mic is muted - Track changes coming from the MQTT LED state entities -Please report back: +The mute button should now: -- Which distro / Pi model you’re using -- Firmware version on the XVF3800 (if known) -- What you see on the LED ring for each LVA state - (idle, listening, thinking, responding, error, muted) +- Keep hardware mute & software/LVA mute fully in sync. +- No short/long press differentiation: + One physical control, one responsibility: mute/unmute. -This will help tighten the mappings and prepare for a follow-up pass that uses the newer `LED_RING_COLOR` capabilities. +`Wake/stop is still done via wake word or a separate GPIO button (you can still wire a standalone Pi button and run mode="gpio" in parallel if desired).` From 3f05bcfc564aef93d697a4ef55c1a005c9cc9df4 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:57:15 -0800 Subject: [PATCH 19/59] Create 99-respeaker-xvf3800.rules Added a udev rule to allow non-root users to access the USB LEDs. --- XVF3800/99-respeaker-xvf3800.rules | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 XVF3800/99-respeaker-xvf3800.rules diff --git a/XVF3800/99-respeaker-xvf3800.rules b/XVF3800/99-respeaker-xvf3800.rules new file mode 100644 index 0000000..cb334f4 --- /dev/null +++ b/XVF3800/99-respeaker-xvf3800.rules @@ -0,0 +1,2 @@ +SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", \ + MODE="0660", GROUP="plugdev", TAG+="uaccess" From 8187760b39c23f4cd4b12f6de88315e46cd95bc7 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:25:59 -0800 Subject: [PATCH 20/59] Update mqtt_controller.py --- linux_voice_assistant/mqtt_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py index 1be1c5d..b44d81a 100644 --- a/linux_voice_assistant/mqtt_controller.py +++ b/linux_voice_assistant/mqtt_controller.py @@ -353,6 +353,7 @@ class MqttController(EventHandler): light_state = { "state": "ON" if data.get("effect") != "off" else "OFF", + "color_mode": "rgb", "brightness": int(data.get("brightness", 0.5) * 255), "color": { "r": data.get("color")[0], From 7abce1060d432de315cd0cc79dece9412d760f2c Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:46:31 -0800 Subject: [PATCH 21/59] Update mpv_player.py Fix play status sync error between mpv and HA when skipping tracks. --- linux_voice_assistant/mpv_player.py | 98 +++++++++++++++++++---------- 1 file changed, 65 insertions(+), 33 deletions(-) diff --git a/linux_voice_assistant/mpv_player.py b/linux_voice_assistant/mpv_player.py index b0966d5..2bf7afd 100644 --- a/linux_voice_assistant/mpv_player.py +++ b/linux_voice_assistant/mpv_player.py @@ -102,8 +102,11 @@ class MpvMediaPlayer: self.set_volume(int(initial_volume * 100)) self.is_playing: bool = False + + # One callback per "logical playback session" (music or announce) self._done_callback: Optional[Callable[[], None]] = None self._done_callback_lock = Lock() + self._pre_duck_volume: Optional[int] = None # When mpv becomes idle, we treat it as end-of-playback. @@ -123,11 +126,11 @@ class MpvMediaPlayer: :param url: A single URL (str/bytes) or a sequence of URLs. :param done_callback: Called once when playback finishes or is stopped. """ - # Ensure player is in a clean state - self.stop() - - with self._done_callback_lock: - self._done_callback = done_callback + # IMPORTANT: + # When starting a new playback session (e.g., Music Assistant "next track"), + # we must NOT fire the previous session's done_callback (it would set HA to IDLE + # even though a new track is starting). + self.stop(run_done_callback=False) playlist: List[str] = [] if isinstance(url, (list, tuple)): @@ -138,20 +141,35 @@ class MpvMediaPlayer: playlist = [url] else: _LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url)) - self._run_done_callback() + if done_callback: + self._call_done_callback(done_callback) return if not playlist: - self._run_done_callback() + if done_callback: + self._call_done_callback(done_callback) return - # Load the full playlist into mpv - self.player.playlist_clear() - for item in playlist: - self.player.playlist_append(item) + with self._done_callback_lock: + self._done_callback = done_callback - self.is_playing = True - self.player.playlist_pos = 0 # Start playing from the first item + # Load the full playlist into mpv + try: + self.player.playlist_clear() + for item in playlist: + self.player.playlist_append(item) + self.player.playlist_pos = 0 # Start playing from the first item + self.is_playing = True + except Exception: + _LOGGER.exception("Failed to start playback") + self.is_playing = False + # pop callback and run it, since we failed to start + cb = None + with self._done_callback_lock: + cb = self._done_callback + self._done_callback = None + if cb: + self._call_done_callback(cb) def pause(self) -> None: """Pauses playback.""" @@ -167,17 +185,33 @@ class MpvMediaPlayer: except Exception: _LOGGER.exception("resume() failed") - def stop(self) -> None: - """Stops playback and clears the playlist.""" - if self.is_playing: - self.is_playing = False - try: - self.player.playlist_clear() - self.player.command("stop") - except Exception: + def stop(self, run_done_callback: bool = True) -> None: + """Stops playback and clears the playlist. + + :param run_done_callback: If False, clears any prior done_callback without running it. + This is critical when replacing one track with another. + """ + # Always clear the stored callback so an "old session" callback can't fire later. + cb: Optional[Callable[[], None]] = None + with self._done_callback_lock: + if run_done_callback: + cb = self._done_callback + self._done_callback = None + + was_playing = self.is_playing + self.is_playing = False + + # Best-effort stop. Even if mpv throws, we still want callback behavior. + try: + self.player.playlist_clear() + self.player.command("stop") + except Exception: + # If this was an external stop request, log it; otherwise keep quiet. + if was_playing: _LOGGER.exception("stop() failed") - finally: - self._run_done_callback() + + if cb: + self._call_done_callback(cb) def set_volume(self, volume: int) -> None: """Sets the player volume from 0 to 100.""" @@ -216,25 +250,23 @@ class MpvMediaPlayer: if active and self.is_playing: _LOGGER.debug("mpv became idle; treating as end-of-playback") self.is_playing = False - self._run_done_callback() - def _run_done_callback(self) -> None: - """Safely runs the done_callback on the main asyncio loop (if any).""" - with self._done_callback_lock: - cb = self._done_callback - self._done_callback = None + cb = None + with self._done_callback_lock: + cb = self._done_callback + self._done_callback = None - if not cb: - return + if cb: + self._call_done_callback(cb) - # If we have an asyncio loop, schedule callback there. + def _call_done_callback(self, cb: Callable[[], None]) -> None: + """Runs a done_callback on the main asyncio loop (if any).""" if self.loop is not None: try: self.loop.call_soon_threadsafe(cb) except Exception: _LOGGER.exception("Error scheduling done_callback on loop") else: - # Fallback: call directly (used in contexts where no loop is passed). try: cb() except Exception: From ab99019d4235c52ad210c8d02021579bcd9798ad Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:47:01 -0800 Subject: [PATCH 22/59] Update entity.py Fix play status sync error between mpv and HA when skipping tracks. --- linux_voice_assistant/entity.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/linux_voice_assistant/entity.py b/linux_voice_assistant/entity.py index ee443b9..bf951f5 100644 --- a/linux_voice_assistant/entity.py +++ b/linux_voice_assistant/entity.py @@ -31,9 +31,6 @@ class ESPHomeEntity: pass -# ----------------------------------------------------------------------------- - - class MediaPlayerEntity(ESPHomeEntity): def __init__( self, @@ -102,13 +99,20 @@ class MediaPlayerEntity(ESPHomeEntity): if msg.has_media_url: announcement = msg.has_announcement and msg.announcement yield from self.play(msg.media_url, announcement=announcement) + elif msg.has_command: if msg.command == MediaPlayerCommand.PAUSE: self.music_player.pause() yield self._update_state(MediaPlayerState.PAUSED) + elif msg.command == MediaPlayerCommand.PLAY: self.music_player.resume() yield self._update_state(MediaPlayerState.PLAYING) + + elif msg.command == MediaPlayerCommand.STOP: + self.music_player.stop() + yield self._update_state(MediaPlayerState.IDLE) + if msg.has_volume: # This block is called when the volume slider changes in HA self.volume = msg.volume # HA sends volume as 0.0-1.0 @@ -119,8 +123,9 @@ class MediaPlayerEntity(ESPHomeEntity): # Save the new volume level to preferences self.state.preferences.volume_level = self.volume self.state.save_preferences() - + yield self._update_state(self.state_enum) + elif isinstance(msg, ListEntitiesRequest): yield ListEntitiesMediaPlayerResponse( object_id=self.object_id, @@ -128,6 +133,7 @@ class MediaPlayerEntity(ESPHomeEntity): name=self.name, supports_pause=True, ) + elif isinstance(msg, SubscribeHomeAssistantStatesRequest): yield self._get_state_message() From 46c39c6ae5fa79bbda70821281ea692ed748325a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:18:39 -0800 Subject: [PATCH 23/59] Update config.json.example cleanup and restore of missing options --- linux_voice_assistant/config.json.example | 229 ++++++++++------------ 1 file changed, 109 insertions(+), 120 deletions(-) diff --git a/linux_voice_assistant/config.json.example b/linux_voice_assistant/config.json.example index 6f64071..989952f 100644 --- a/linux_voice_assistant/config.json.example +++ b/linux_voice_assistant/config.json.example @@ -1,133 +1,122 @@ { + // --------------------------------------------------------------------------- + // Required + // --------------------------------------------------------------------------- "app": { - // Human-readable name; also used to derive the MQTT device_id - // Example: "Bedroom Voice Assistant" -> device_id = "bedroom_voice_assistant" - "name": "Linux Voice Assistant" - }, + // Required + "name": "My Linux Voice Assistant", - "mqtt": { - // Standard MQTT settings (optional if you don't use MQTT/HA discovery) - "host": "192.168.1.2", - "port": 1883, - "username": "mqtt_username", - "password": "mqtt_password" + // Optional + "wakeup_sound": "sounds/wake_word_triggered.flac", + "timer_finished_sound": "sounds/timer_finished.flac", + "preferences_file": "preferences.json", + "debug": false }, // --------------------------------------------------------------------------- - // LED CONFIGURATION + // Optional: Audio I/O // --------------------------------------------------------------------------- - // - // The LVA has a single logical LED backend that reflects the internal - // voice states (idle/listening/thinking/responding/error) and mic mute. - // - // "led_type" selects WHICH hardware is used: - // - "dotstar" = external APA102/DotStar strip - // - "neopixel" = external WS281x/NeoPixel strip - // - "xvf3800" = use the XVF3800 built-in LED ring via USB control - // - // "interface" is only meaningful for dotstar/neopixel: - // - "spi" = hardware SPI (e.g. for APA102 strips) - // - "gpio" = bit-banged output on GPIO pins - // - // For "xvf3800", LEDs are always controlled over USB; "interface", - // "clock_pin", "data_pin", and "num_leds" are ignored by the driver. - // - "led": { - // ------------------------------------------------------------------------- - // Example 1: XVF3800 built-in LED ring - // ------------------------------------------------------------------------- - // Use the XVF3800 ring as the LVA's state indicator (recommended when - // the device is present). The driver talks to the XVF3800 over USB - // using the XMOS control protocol (LED_EFFECT, LED_COLOR, etc.). - // - "led_type": "xvf3800", - - // For xvf3800, the interface is always USB internally. - // This field is kept for schema compatibility but is ignored. - "interface": "gpio", - - // Ignored when led_type == "xvf3800" - "clock_pin": 13, - "data_pin": 12, - - // Ignored when led_type == "xvf3800" (ring length is fixed in hardware). - // The MQTT "Number of LEDs" entity will not affect the XVF3800 ring. - "num_leds": 10 - - // ------------------------------------------------------------------------- - // Example 2: (commented out) DotStar on Grove port - // ------------------------------------------------------------------------- - /* - "led_type": "dotstar", - "interface": "gpio", - - // For ReSpeaker 2-Mic HAT Grove port: - // Yellow wire -> GPIO12 (data) - // White wire -> GPIO13 (clock) - "clock_pin": 13, - "data_pin": 12, - - // Default number of LEDs for a DotStar strip. - // This is overridden by preferences.json after first run. - "num_leds": 16 - */ - - // ------------------------------------------------------------------------- - // Example 3: (commented out) NeoPixel on Grove port - // ------------------------------------------------------------------------- - /* - "led_type": "neopixel", - "interface": "gpio", - - // For NeoPixel, only data_pin is used. - // Grove yellow -> GPIO12 (data) - "clock_pin": 13, // Ignored for NeoPixel - "data_pin": 12, - - "num_leds": 16 - */ - }, - "button": { - // "mode" can be: - // - "gpio" : ReSpeaker 2-Mic HAT or generic Pi GPIO button - // - "xvf3800" : Onboard mute button of the ReSpeaker XVF3800 - "mode": "gpio", - - // GPIO button only (ignored for xvf3800): - "enabled": true, - "pin": 17, - "long_press_seconds": 1.0, - - // XVF3800 mode has no extra options (for now). - // It always acts as a "mute toggle" only. - }, - - // --------------------------------------------------------------------------- - // AUDIO CONFIGURATION - // --------------------------------------------------------------------------- - // - // The XVF3800 presents itself as a standard USB audio device: - // - Input: "reSpeaker XVF3800 4-Mic Array Analog Stereo" - // - Output: "reSpeaker XVF3800 4-Mic Array Analog Stereo" - // - // With PipeWire or PulseAudio, these names show up in: - // script/run --list-input-devices - // script/run --list-output-devices - // "audio": { - // Input device name (or leave omitted/empty to use the system default). - "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", + // Optional: + // - null/omit => default microphone + // - string name => exact device name (soundcard backend) + // - string number => index into soundcard device list (e.g. "0", "1", ...) + "input_device": null, - // Default block size for the wake-word + streaming pipeline. + // Optional: audio capture block size "input_block_size": 1024, - // Output device used for TTS and media playback. - // For XVF3800 you can: - // - Use its own speaker output, or - // - Use HDMI/other sinks instead. - "output_device": "pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441253700076-00.analog-stereo" - } + // Optional: + // - null/omit => mpv chooses default output + // - string => mpv audio-device (e.g. "pulse/alsa_output....") + "output_device": null + }, - // You can also include other sections (zeroconf, additional settings, etc.) - // as needed by the rest of the LVA configuration model. + // --------------------------------------------------------------------------- + // Optional: Wake word config + // --------------------------------------------------------------------------- + "wake_word": { + // Optional: if omitted/empty, code falls back to defaults: + // ["wakewords", "wakewords/openWakeWord"] + "directories": [], + + // Optional: default wake word model id + "model": "okay_nabu", + + // Optional: stop model id (micro wake word) + "stop_model": "stop", + + // Optional: debounce/refractory window after wake word triggers + "refractory_seconds": 2.0, + + // Optional: directory for downloaded models (relative to repo) + "download_dir": "local" + }, + + // --------------------------------------------------------------------------- + // Optional: ESPHome protocol server settings (the LVA exposes an ESPHome API) + // --------------------------------------------------------------------------- + "esphome": { + "host": "0.0.0.0", + "port": 6053 + }, + + // --------------------------------------------------------------------------- + // Optional: LEDs + // --------------------------------------------------------------------------- + "led": { + // Optional: if false, LED controller runs in no-op mode + "enabled": true, + + // "dotstar" | "neopixel" | "xvf3800" + "led_type": "dotstar", + + // "spi" | "gpio" | "usb" + // - dotstar: "spi" or "gpio" + // - neopixel: "spi" or "gpio" + // - xvf3800: "usb" (current intended support) + "interface": "spi", + + // GPIO pins (BCM-ish via adafruit "board.Dxx" mapping) — used when interface="gpio" + // For DotStar GPIO: both clock_pin + data_pin are used + // For NeoPixel GPIO: only data_pin is used (clock_pin ignored) + // For xvf3800: unused and ignored + "clock_pin": 13, + "data_pin": 12, + + // Default number of LEDs (may be overridden by preferences.json after first run) + "num_leds": 3 + }, + + // --------------------------------------------------------------------------- + // Optional: MQTT (enabled automatically when host is set) + // --------------------------------------------------------------------------- + "mqtt": { + // If set, mqtt.enabled becomes true automatically + "host": null, + + "port": 1883, + "username": null, + "password": null + }, + + // --------------------------------------------------------------------------- + // Optional: Button controller + // --------------------------------------------------------------------------- + "button": { + // Master enable + "enabled": false, + + // "gpio" | "xvf3800" + // - gpio: short/long press logic (pin + long_press_seconds) + // - xvf3800: uses the XVF3800 built-in mute button as a pure mute toggle + "mode": "gpio", + + // gpio mode only + "pin": 17, + "long_press_seconds": 1.0, + + // Polling interval (used by both gpio + xvf3800 button controllers) + "poll_interval_seconds": 0.01 + } } From 99157b9a8a982f1412417adb169bd5f92393be22 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:44:52 -0800 Subject: [PATCH 24/59] Add AEC persistance service file --- service/aec-module-load.service | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 service/aec-module-load.service diff --git a/service/aec-module-load.service b/service/aec-module-load.service new file mode 100644 index 0000000..60d9838 --- /dev/null +++ b/service/aec-module-load.service @@ -0,0 +1,21 @@ +[Unit] +Description=Load PipeWire echo-cancel module for LVA +After=pipewire-pulse.service +Wants=pipewire-pulse.service + +[Service] +Type=oneshot +RemainAfterExit=yes + +# Use a shell so we can do a little logic: +# 1) Try to unload any existing echo-cancel module (ignore errors) +# 2) Load the WebRTC echo cancel module with our tuning options +ExecStart=/usr/bin/bash -lc '\ + pactl unload-module module-echo-cancel || true; \ + pactl load-module module-echo-cancel \ + aec_method=webrtc \ + aec_args="analog_gain_control=0 digital_gain_control=1 voice_detection=1" \ +' + +[Install] +WantedBy=default.target From 22e94fc92b45e81586c46fb4241ea9f84cd71550 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:13:03 -0800 Subject: [PATCH 25/59] Update linux-voice-assistant-2mic-install.md --- docs/linux-voice-assistant-2mic-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md index 826b795..80066d7 100644 --- a/docs/linux-voice-assistant-2mic-install.md +++ b/docs/linux-voice-assistant-2mic-install.md @@ -24,7 +24,7 @@ This guide reproduces a working setup of the **linux-voice-assistant** project w sudo apt update sudo apt upgrade sudo apt install build-essential git \ - libmpv-dev mpv python3-dev + libmpv-dev mpv python3-dev python3-venv sudo reboot ``` From bddbbbe8990159aa3580f6af3903766a5690e221 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 20 Dec 2025 16:20:51 -0800 Subject: [PATCH 26/59] Update 99-respeaker-xvf3800.rules Add rule to disable USB autosuspend for the XVF3800. Attempting to address the XVF3800 becoming non-responsive after a period of inactivity. --- XVF3800/99-respeaker-xvf3800.rules | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/XVF3800/99-respeaker-xvf3800.rules b/XVF3800/99-respeaker-xvf3800.rules index cb334f4..26a4b70 100644 --- a/XVF3800/99-respeaker-xvf3800.rules +++ b/XVF3800/99-respeaker-xvf3800.rules @@ -1,2 +1,5 @@ -SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", \ - MODE="0660", GROUP="plugdev", TAG+="uaccess" +# XVF3800 permissions +SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", MODE="0660", GROUP="plugdev", TAG+="uaccess" + +# XVF3800 power policy +ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", TEST=="power/control", ATTR{power/control}="on" From ae9b834ee33b0cee5dd9fb371a04827d862a4e01 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 22 Dec 2025 09:54:01 -0800 Subject: [PATCH 27/59] Update audio_engine.py Revert refractory bug fix --- linux_voice_assistant/audio_engine.py | 30 +++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/linux_voice_assistant/audio_engine.py b/linux_voice_assistant/audio_engine.py index 8b23095..897831e 100644 --- a/linux_voice_assistant/audio_engine.py +++ b/linux_voice_assistant/audio_engine.py @@ -5,7 +5,7 @@ import logging import threading import time import warnings -from typing import Dict, List, Optional, Union +from typing import List, Optional, Union import numpy as np import soundcard as sc @@ -56,9 +56,7 @@ class AudioEngine: micro_inputs: List[np.ndarray] = [] oww_inputs: List[np.ndarray] = [] has_oww = False - - # Track last activation time per wake word (by id or phrase) - last_active_by_id: Dict[str, float] = {} + last_active: Optional[float] = None try: while True: @@ -77,7 +75,7 @@ class AudioEngine: oww_features.reset() # Allow immediate activation (don't enforce refractory here) - last_active_by_id.clear() + last_active = None # Flush hardware buffer try: @@ -175,24 +173,16 @@ class AudioEngine: wake_phrase = getattr( wake_word, "wake_word", "wake word" ) - wake_id_attr = getattr(wake_word, "id", None) - # Use id if available, otherwise phrase string as key - id_key = wake_id_attr or wake_phrase - + wake_id = getattr(wake_word, "id", None) _LOGGER.debug( "Wake word ACTIVATED: %s (id=%s)", wake_phrase, - wake_id_attr, + wake_id, ) - now = time.monotonic() - last_active = last_active_by_id.get(id_key) - - # If refractory_seconds <= 0, disable gating - refractory = max(self.state.refractory_seconds, 0.0) - if (last_active is None) or ( - (now - last_active) > refractory + (now - last_active) + > self.state.refractory_seconds ): _LOGGER.debug( "Wake word ACCEPTED (phrase=%s); " @@ -201,19 +191,19 @@ class AudioEngine: 0.0 if last_active is None else now - last_active, - refractory, + self.state.refractory_seconds, ) - last_active_by_id[id_key] = now self.state.loop.call_soon_threadsafe( self.state.satellite.wakeup, wake_word ) + last_active = now else: _LOGGER.debug( "Wake word REJECTED by refractory period " "(phrase=%s, Δt=%.2f < %.2f)", wake_phrase, now - last_active, - refractory, + self.state.refractory_seconds, ) # Stop word detection From 77c22994535ca92b832f38706d969b9f3cec55c9 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:46:09 -0800 Subject: [PATCH 28/59] Update audio_engine.py reverting the revert --- linux_voice_assistant/audio_engine.py | 30 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/linux_voice_assistant/audio_engine.py b/linux_voice_assistant/audio_engine.py index 897831e..8b23095 100644 --- a/linux_voice_assistant/audio_engine.py +++ b/linux_voice_assistant/audio_engine.py @@ -5,7 +5,7 @@ import logging import threading import time import warnings -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union import numpy as np import soundcard as sc @@ -56,7 +56,9 @@ class AudioEngine: micro_inputs: List[np.ndarray] = [] oww_inputs: List[np.ndarray] = [] has_oww = False - last_active: Optional[float] = None + + # Track last activation time per wake word (by id or phrase) + last_active_by_id: Dict[str, float] = {} try: while True: @@ -75,7 +77,7 @@ class AudioEngine: oww_features.reset() # Allow immediate activation (don't enforce refractory here) - last_active = None + last_active_by_id.clear() # Flush hardware buffer try: @@ -173,16 +175,24 @@ class AudioEngine: wake_phrase = getattr( wake_word, "wake_word", "wake word" ) - wake_id = getattr(wake_word, "id", None) + wake_id_attr = getattr(wake_word, "id", None) + # Use id if available, otherwise phrase string as key + id_key = wake_id_attr or wake_phrase + _LOGGER.debug( "Wake word ACTIVATED: %s (id=%s)", wake_phrase, - wake_id, + wake_id_attr, ) + now = time.monotonic() + last_active = last_active_by_id.get(id_key) + + # If refractory_seconds <= 0, disable gating + refractory = max(self.state.refractory_seconds, 0.0) + if (last_active is None) or ( - (now - last_active) - > self.state.refractory_seconds + (now - last_active) > refractory ): _LOGGER.debug( "Wake word ACCEPTED (phrase=%s); " @@ -191,19 +201,19 @@ class AudioEngine: 0.0 if last_active is None else now - last_active, - self.state.refractory_seconds, + refractory, ) + last_active_by_id[id_key] = now self.state.loop.call_soon_threadsafe( self.state.satellite.wakeup, wake_word ) - last_active = now else: _LOGGER.debug( "Wake word REJECTED by refractory period " "(phrase=%s, Δt=%.2f < %.2f)", wake_phrase, now - last_active, - self.state.refractory_seconds, + refractory, ) # Stop word detection From 4bea3f63d215c79f3e8a4d6e5f5b5b4e3a35d94c Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:43:36 -0800 Subject: [PATCH 29/59] Update hey_luna.json Fixed the friendly name for Hey Luna --- wakewords/hey_luna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wakewords/hey_luna.json b/wakewords/hey_luna.json index a293cbc..e27c6cb 100644 --- a/wakewords/hey_luna.json +++ b/wakewords/hey_luna.json @@ -1,6 +1,6 @@ { "type": "micro", - "wake_word": "hey_luna", + "wake_word": "Hey Luna", "author": "adamlonsdale", "website": "https://github.com/adamlonsdale", "model": "hey_luna.tflite", From 7bbe0ed96f452187cc5da04f49b5ae7f7d0f7d3a Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:03:18 -0800 Subject: [PATCH 30/59] Add test file that captures microphone audio uses the same code that the LVA uses to capture microphone audio. Defaults to downmix and accepts left and right as variable for --channel. --- tests/lva_mic_capture.py | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/lva_mic_capture.py diff --git a/tests/lva_mic_capture.py b/tests/lva_mic_capture.py new file mode 100644 index 0000000..f7dce80 --- /dev/null +++ b/tests/lva_mic_capture.py @@ -0,0 +1,73 @@ +import argparse +import soundcard as sc +import numpy as np +import wave + +DEVICE = "reSpeaker XVF3800 4-Mic Array Analog Stereo" +SECONDS = 5 +SR = 16000 +BLOCK = 1024 + +def main(): + parser = argparse.ArgumentParser(description="Record from soundcard mic to WAV.") + parser.add_argument( + "--channel", + choices=["mix", "left", "right"], + default="mix", + help="mix=record 1ch (downmix/default). left/right=record 2ch and select.", + ) + parser.add_argument("--seconds", type=int, default=SECONDS) + parser.add_argument("--device", type=str, default=DEVICE) + parser.add_argument("--out", type=str, default="/tmp/lva_soundcard.wav") + args = parser.parse_args() + + if args.channel == "mix": + in_ch = 1 + out_ch = 1 + out_path = args.out + else: + in_ch = 2 + out_ch = 1 + # If user didn't override --out, make filename reflect selection + if args.out == "/tmp/lva_soundcard.wav": + out_path = f"/tmp/lva_soundcard_{args.channel}.wav" + else: + out_path = args.out + + mic = sc.get_microphone(args.device, include_loopback=False) + print("Using mic:", mic.name) + print(f"Mode: {args.channel} (request channels={in_ch} -> write channels={out_ch})") + + frames = [] + with mic.recorder(samplerate=SR, channels=in_ch, blocksize=BLOCK) as r: + for _ in range(int(SR * args.seconds / BLOCK)): + buf = r.record(BLOCK) + + if in_ch == 1: + # buf can be (BLOCK,) or (BLOCK, 1) depending on backend + mono = buf.reshape(-1) + sample = mono + else: + # buf should be (BLOCK, 2) + if buf.ndim != 2 or buf.shape[1] < 2: + raise RuntimeError(f"Expected stereo buffer, got shape {buf.shape}") + idx = 0 if args.channel == "left" else 1 + sample = buf[:, idx] + + rms = float(np.sqrt(np.mean(np.square(sample)))) if sample.size else 0.0 + print(f"rms={rms:.6f}") + + pcm16 = (np.clip(sample, -1.0, 1.0) * 32767.0).astype(" Date: Thu, 1 Jan 2026 15:16:13 -0800 Subject: [PATCH 31/59] Per LED Control Added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated per-LED rendering into the existing effect/state pipeline: solid/off uses ring frames directly, blink toggles between frames (color ↔ off), pulse now rewrites LED_RING_COLOR with scaled RGB per step (instead of relying on global brightness), spin uses a single “dot” moving around the 12 LEDs. Ensured the controller uses the XVF3800’s fixed ring size (12) where appropriate. Fixed the “stuck in Responding” issue by subscribing the voice_idle handler so Idle effects reapply correctly when returning to IDLE. --- linux_voice_assistant/led_controller.py | 165 +++++++++++++++++++++--- 1 file changed, 147 insertions(+), 18 deletions(-) diff --git a/linux_voice_assistant/led_controller.py b/linux_voice_assistant/led_controller.py index 0b936e1..3b65fe5 100644 --- a/linux_voice_assistant/led_controller.py +++ b/linux_voice_assistant/led_controller.py @@ -100,6 +100,8 @@ class LedController(EventHandler): self._xvf3800_backend = XVF3800LedBackend() self._backend_mode = "xvf3800" self._is_ready = True + # XVF3800 ring has a fixed LED count (typically 12) + self.num_leds = int(getattr(self._xvf3800_backend, "ring_led_count", 12)) _LOGGER.info( "LED Controller initialized for XVF3800 USB LED ring (num_leds=%d).", self.num_leds, @@ -280,38 +282,109 @@ class LedController(EventHandler): # LED effect coroutines # ----------------------------------------------------------------------- + + # ----------------------------------------------------------------------- + # XVF3800 per-LED helpers (newer firmware) + # ----------------------------------------------------------------------- + + def _xvf3800_has_per_led(self) -> bool: + return bool(getattr(self._xvf3800_backend, "supports_per_led", False)) + + def _xvf3800_ring_count(self) -> int: + return int(getattr(self._xvf3800_backend, "ring_led_count", 12)) + + def _xvf3800_rgb_clamp(self, color): + r, g, b = color + return ( + max(0, min(255, int(r))), + max(0, min(255, int(g))), + max(0, min(255, int(b))), + ) + + def _xvf3800_brightness_255(self, brightness: float) -> int: + b = max(0.0, min(1.0, float(brightness))) + return int(b * 255) + + def _xvf3800_apply_ring_solid(self, color, brightness: float) -> None: + """Per-LED solid ring. + + NOTE: Some XVF3800 firmwares do not apply LED_BRIGHTNESS to LED_RING_COLOR output. + To keep behavior consistent, we scale the RGB values by brightness and write the ring + colors directly. + """ + if not self._xvf3800_backend: + return + r, g, b = self._xvf3800_rgb_clamp(color) + brightness = max(0.0, min(1.0, float(brightness))) + + # Ensure firmware effects don't override per-LED output. + try: + self._xvf3800_backend.set_effect(0) + except Exception: + pass + + rs = int(r * brightness) + gs = int(g * brightness) + bs = int(b * brightness) + self._xvf3800_backend.set_ring_solid(rs, gs, bs) + + def _xvf3800_apply_ring_clear(self) -> None: + if not self._xvf3800_backend: + return + try: + self._xvf3800_backend.set_effect(0) + except Exception: + pass + self._xvf3800_backend.clear_ring() + async def startup_sequence(self, color=None, brightness=None): # Use a simple green blink on startup for all backends. await self.blink(_GREEN, 1.0) + async def off(self, color, brightness): if not (self._enabled and self._is_ready): return if self._backend_mode == "xvf3800": + # Prefer per-LED ring control when supported by firmware. + if self._xvf3800_has_per_led(): + try: + self._xvf3800_apply_ring_clear() + except Exception: + _LOGGER.exception("Error sending per-LED OFF to XVF3800") + return + await self._xvf3800_apply_effect("off", _OFF, 0.0) return self.leds.fill(_OFF) self.leds.show() + async def solid(self, color: Tuple[int, int, int], brightness: float): if not (self._enabled and self._is_ready): return if self._backend_mode == "xvf3800": + if self._xvf3800_has_per_led(): + try: + self._xvf3800_apply_ring_solid(color, brightness) + except Exception: + _LOGGER.exception("Error sending per-LED SOLID to XVF3800") + return + await self._xvf3800_apply_effect("solid", color, brightness) return r, g, b = color - self.leds.fill( - (int(r * brightness), int(g * brightness), int(b * brightness)) - ) + self.leds.fill((int(r * brightness), int(g * brightness), int(b * brightness))) self.leds.show() async def blink(self, color, brightness=1.0): await self.medium_blink(color, brightness) + async def _base_pulse( self, effect_name: str, @@ -323,6 +396,26 @@ class LedController(EventHandler): return if self._backend_mode == "xvf3800": + if self._xvf3800_has_per_led(): + try: + r, g, b = self._xvf3800_rgb_clamp(color) + # Pulse by scaling the per-LED RGB values (some firmwares don't apply LED_BRIGHTNESS to LED_RING_COLOR). + self._xvf3800_backend.set_effect(0) + brightness = max(0.0, min(1.0, float(brightness))) + while True: + for i in range(0, 101, 10): + s = (i / 100.0) * brightness + self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s)) + await asyncio.sleep(speed) + for i in range(100, -1, -10): + s = (i / 100.0) * brightness + self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s)) + await asyncio.sleep(speed) + return + except Exception: + _LOGGER.exception("Error sending per-LED PULSE to XVF3800") + return + await self._xvf3800_apply_effect(effect_name, color, brightness) return @@ -331,16 +424,12 @@ class LedController(EventHandler): while True: for i in range(0, 101, 10): mod = i / 100.0 * brightness - self.leds.fill( - (int(r * mod), int(g * mod), int(b * mod)) - ) + self.leds.fill((int(r * mod), int(g * mod), int(b * mod))) self.leds.show() await asyncio.sleep(speed) for i in range(100, -1, -10): mod = i / 100.0 * brightness - self.leds.fill( - (int(r * mod), int(g * mod), int(b * mod)) - ) + self.leds.fill((int(r * mod), int(g * mod), int(b * mod))) self.leds.show() await asyncio.sleep(speed) except asyncio.CancelledError: @@ -357,6 +446,7 @@ class LedController(EventHandler): async def fast_pulse(self, color, brightness): await self._base_pulse("fast_pulse", color, brightness, 0.008) + async def _base_blink( self, effect_name: str, @@ -368,16 +458,34 @@ class LedController(EventHandler): return if self._backend_mode == "xvf3800": + if self._xvf3800_has_per_led(): + try: + r, g, b = self._xvf3800_rgb_clamp(color) + self._xvf3800_backend.set_effect(0) + brightness = max(0.0, min(1.0, float(brightness))) + while True: + # On + self._xvf3800_backend.set_ring_solid(int(r * brightness), int(g * brightness), int(b * brightness)) + await asyncio.sleep(speed) + # Off + self._xvf3800_backend.clear_ring() + await asyncio.sleep(speed) + except asyncio.CancelledError: + try: + self._xvf3800_apply_ring_clear() + except Exception: + pass + return + except Exception: + _LOGGER.exception("Error sending per-LED BLINK to XVF3800") + return + await self._xvf3800_apply_effect(effect_name, color, brightness) return try: r, g, b = color - bright_color = ( - int(r * brightness), - int(g * brightness), - int(b * brightness), - ) + bright_color = (int(r * brightness), int(g * brightness), int(b * brightness)) while True: self.leds.fill(bright_color) self.leds.show() @@ -399,6 +507,7 @@ class LedController(EventHandler): async def fast_blink(self, color, brightness): await self._base_blink("fast_blink", color, brightness, 0.1) + async def spin( self, color: Tuple[int, int, int], brightness: float, speed: float = 0.1 ): @@ -406,6 +515,30 @@ class LedController(EventHandler): return if self._backend_mode == "xvf3800": + if self._xvf3800_has_per_led(): + try: + r, g, b = self._xvf3800_rgb_clamp(color) + ring_n = self._xvf3800_ring_count() + self._xvf3800_backend.set_effect(0) + self._xvf3800_backend.set_brightness(self._xvf3800_brightness_255(brightness)) + + i = 0 + while True: + colors = [(0, 0, 0)] * ring_n + colors[i % ring_n] = (r, g, b) + self._xvf3800_backend.set_ring_rgb(colors) + i += 1 + await asyncio.sleep(speed) + except asyncio.CancelledError: + try: + self._xvf3800_apply_ring_clear() + except Exception: + pass + return + except Exception: + _LOGGER.exception("Error sending per-LED SPIN to XVF3800") + return + await self._xvf3800_apply_effect("spin", color, brightness) return @@ -427,10 +560,6 @@ class LedController(EventHandler): self.leds.fill(_OFF) self.leds.show() - # ----------------------------------------------------------------------- - # State Subscriptions - # ----------------------------------------------------------------------- - @subscribe def voice_idle(self, data: dict): self._apply_state_effect("idle") From a7a4f47f52ea405a7f5126b6ffe8ba71ce644039 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:17:41 -0800 Subject: [PATCH 32/59] Add per LED control Prevented MQTT command/state feedback loops while keeping HA-friendly behavior: accepts retained /state topics during a short startup bootstrap window to restore last-known settings, then stops processing /state messages and unsubscribes from .../+/state after bootstrap. Ensures runtime control is driven only by /set topics, while /state remains publish-only for HA UI synchronization. Adds explicit logging around bootstrap completion to make startup behavior clear. --- linux_voice_assistant/mqtt_controller.py | 39 +++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/linux_voice_assistant/mqtt_controller.py b/linux_voice_assistant/mqtt_controller.py index b44d81a..aadb88f 100644 --- a/linux_voice_assistant/mqtt_controller.py +++ b/linux_voice_assistant/mqtt_controller.py @@ -70,6 +70,12 @@ class MqttController(EventHandler): "light_state": f"{self._topic_prefix}/{state_name}_light/state", } + # During startup we accept retained */state topics from the broker to restore + # Home Assistant's last-known settings. After bootstrap, we ignore */state topics + # to avoid feedback loops (we only accept */set as commands). + self._bootstrap_state_sync = True + self._bootstrap_ends_at: Optional[float] = None + self._client = mqtt.Client() self._client.on_connect = self._on_connect self._client.on_message = self._on_message @@ -102,10 +108,26 @@ class MqttController(EventHandler): _LOGGER.info("Connected to MQTT broker") client.subscribe(f"{self._topic_prefix}/+/set") client.subscribe(f"{self._topic_prefix}/+/state") + # End bootstrap a moment after subscribe so retained state messages can arrive. + self._bootstrap_ends_at = self.loop.time() + 2.0 + self.loop.call_later(2.0, self._end_bootstrap_state_sync) self._publish_discovery_configs() else: _LOGGER.error("Failed to connect to MQTT, return code %d", rc) + def _end_bootstrap_state_sync(self): + # Called on the asyncio loop thread. + self._bootstrap_state_sync = False + # We only needed */state subscriptions to ingest retained settings at startup. + # After bootstrap, unsubscribe to avoid feedback loops and log spam. + try: + self._client.unsubscribe(f"{self._topic_prefix}/+/state") + except Exception: + _LOGGER.debug("Failed to unsubscribe from */state topics", exc_info=True) + _LOGGER.debug( + "MQTT bootstrap state sync complete; unsubscribed from */state and will ignore any state topics" + ) + def _on_message(self, client, userdata, msg): try: topic = msg.topic @@ -113,15 +135,24 @@ class MqttController(EventHandler): _LOGGER.debug( "Received MQTT message on topic %s (from MQTT thread)", msg.topic ) + retain = bool(getattr(msg, "retain", False)) self.loop.call_soon_threadsafe( - self._handle_message_on_loop, topic, payload + self._handle_message_on_loop, topic, payload, retain ) except Exception: _LOGGER.exception("Error in _on_message") - def _handle_message_on_loop(self, topic: str, payload: str): + def _handle_message_on_loop(self, topic: str, payload: str, retained: bool = False): _LOGGER.debug("Handling message for topic %s on main loop", topic) + # Prevent feedback loops: we only treat */set as commands. + # However during bootstrap we accept retained */state values to restore HA settings. + if topic.endswith("/state"): + if not (self._bootstrap_state_sync and retained): + _LOGGER.debug("Ignoring MQTT state topic: %s (retained=%s, bootstrap=%s)", topic, retained, self._bootstrap_state_sync) + return + + if topic == self.topics["mute"]["command"]: # Publish event for MicMuteHandler to process self.event_bus.publish( @@ -183,12 +214,12 @@ class MqttController(EventHandler): effect_id = payload.lower().replace(" ", "_") self.event_bus.publish( f"set_{state_name}_effect", - {"effect": effect_id, "retained": True}, + {"effect": effect_id, "retained": retained}, ) elif topic == state_topics["light_state"]: try: data = json.loads(payload) - data["retained"] = True + data["retained"] = retained self.event_bus.publish( f"set_{state_name}_color", data ) From 5315c56556f9bdfd7121e74b9712ef1ef677365e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:18:54 -0800 Subject: [PATCH 33/59] Add per LED support Added support for the XVF3800 per-LED command LED_RING_COLOR (12-LED ring, 48-byte payload). Implemented/readied the device control logic so the backend can: write per-LED frames (set_ring_rgb / set_ring_solid / clear_ring), detect availability (supports_per_led) and fall back if unsupported. Improved robustness/diagnostics for USB control transfers (more consistent request composition + debug logging around writes). --- linux_voice_assistant/xvf3800_led_backend.py | 245 +++++++++++++++---- 1 file changed, 200 insertions(+), 45 deletions(-) diff --git a/linux_voice_assistant/xvf3800_led_backend.py b/linux_voice_assistant/xvf3800_led_backend.py index fb3d3cd..f3a9469 100644 --- a/linux_voice_assistant/xvf3800_led_backend.py +++ b/linux_voice_assistant/xvf3800_led_backend.py @@ -1,67 +1,117 @@ +"""XVF3800 USB LED ring backend. + +This backend supports two generations of LED control: + +1) Legacy/global LED control (older firmware): + - LED_EFFECT, LED_BRIGHTNESS, LED_SPEED, LED_COLOR + +2) Per-LED ring control (newer firmware): + - LED_RING_COLOR: 12x uint32 values, one per LED (0xRRGGBB) + +We intentionally keep the implementation minimal and self-contained so it can be +used by LVA without shelling out to xvf_host.py. +""" + +from __future__ import annotations + import logging import struct -from typing import Optional, Sequence +from typing import List, Optional, Sequence, Tuple -import usb.core -import usb.util +import usb.core # type: ignore[import] +import usb.util # type: ignore[import] _LOGGER = logging.getLogger(__name__) CONTROL_SUCCESS = 0 SERVICER_COMMAND_RETRY = 64 -# name, resid, cmdid, length, access, type +# name -> (resid, cmdid, count, access, type) PARAMETERS = { - # GPO_SERVICER_RESID LED controls (legacy interface) + # --------------------------------------------------------------------- + # APPLICATION_SERVICER_RESID (core info) + # --------------------------------------------------------------------- + "VERSION": (48, 0, 3, "ro", "uint8"), + + # --------------------------------------------------------------------- + # GPO_SERVICER_RESID (LED controls) + # --------------------------------------------------------------------- + # Legacy/global controls "LED_EFFECT": (20, 12, 1, "rw", "uint8"), "LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8"), "LED_GAMMIFY": (20, 14, 1, "rw", "uint8"), "LED_SPEED": (20, 15, 1, "rw", "uint8"), "LED_COLOR": (20, 16, 1, "rw", "uint32"), + + # Newer firmware: per-LED ring control (12 WS2812 LEDs) + "LED_RING_COLOR": (20, 19, 12, "rw", "uint32"), } class _ReSpeaker: - """Low-level USB control wrapper for XVF3800 parameters we care about.""" + """Low-level USB control wrapper for XVF3800 parameters.""" - TIMEOUT = 100_000 + TIMEOUT_MS = 100_000 + VID = 0x2886 + PID = 0x001A - def __init__(self, dev: usb.core.Device) -> None: # type: ignore[name-defined] + def __init__(self, dev: "usb.core.Device") -> None: # type: ignore[name-defined] self.dev = dev + # ------------------------------------------------------------------ + # Encoding / decoding helpers + # ------------------------------------------------------------------ + + def _pack_values(self, data_type: str, values: Sequence[int]) -> bytes: + if data_type == "uint8": + return bytes([int(v) & 0xFF for v in values]) + if data_type == "uint32": + return b"".join(struct.pack(" List[int]: + if data_type == "uint8": + return list(raw[:count]) + if data_type == "uint32": + return list(struct.unpack("<" + "I" * count, raw[: count * 4])) + if data_type == "int32": + return list(struct.unpack("<" + "i" * count, raw[: count * 4])) + raise ValueError(f"Unsupported data type '{data_type}'") + + def _read_length(self, data_type: str, count: int) -> int: + # +1 for status byte returned by XMOS vendor control read + if data_type == "uint8": + return count + 1 + if data_type in ("uint32", "int32"): + return (count * 4) + 1 + raise ValueError(f"Unsupported data type '{data_type}'") + + # ------------------------------------------------------------------ + # Public parameter IO + # ------------------------------------------------------------------ + def write(self, name: str, data_list: Sequence[int]) -> None: try: - data = PARAMETERS[name] + resid, cmdid, count, access, data_type = PARAMETERS[name] except KeyError as exc: raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc - resid, cmdid, count, access, data_type = data - if access == "ro": raise ValueError(f"{name} is read-only") if len(data_list) != count: raise ValueError(f"{name} expects {count} values, got {len(data_list)}") - payload = bytearray() - - if data_type == "uint8": - for v in data_list: - payload += int(v).to_bytes(1, byteorder="little", signed=False) - elif data_type in ("uint32", "int32"): - fmt = b"I" if data_type == "uint32" else b"i" - for v in data_list: - payload += struct.pack(fmt, int(v)) - else: - # We don't actually use other types for LED controls. - raise ValueError(f"Unsupported data type '{data_type}' for {name}") + payload = self._pack_values(data_type, data_list) _LOGGER.debug( - "XVF3800 write: name=%s resid=%s cmdid=%s payload=%s", + "XVF3800 write: name=%s resid=%s cmdid=%s payload_len=%d", name, resid, cmdid, - list(payload), + len(payload), ) self.dev.ctrl_transfer( @@ -72,14 +122,53 @@ class _ReSpeaker: cmdid, resid, payload, - self.TIMEOUT, + self.TIMEOUT_MS, ) + def read(self, name: str, max_retries: int = 10) -> List[int]: + try: + resid, cmdid, count, access, data_type = PARAMETERS[name] + except KeyError as exc: + raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc + + if access == "wo": + raise ValueError(f"{name} is write-only") + + length = self._read_length(data_type, count) + wValue = 0x80 | cmdid # per XMOS protocol: read is (0x80 | cmdid) + + attempt = 0 + while True: + attempt += 1 + resp = self.dev.ctrl_transfer( + usb.util.CTRL_IN + | usb.util.CTRL_TYPE_VENDOR + | usb.util.CTRL_RECIPIENT_DEVICE, + 0, + wValue, + resid, + length, + self.TIMEOUT_MS, + ) + + if not resp: + raise RuntimeError("Empty response from XVF3800 control read") + + status = int(resp[0]) + if status == CONTROL_SUCCESS: + raw = bytes(resp[1:]) + return self._unpack_values(data_type, raw, count) + + if status == SERVICER_COMMAND_RETRY and attempt < max_retries: + continue + + raise RuntimeError(f"XVF3800 control read failed (status={status}, name={name})") + def close(self) -> None: usb.util.dispose_resources(self.dev) -def _find_device(vid: int = 0x2886, pid: int = 0x001A) -> Optional[_ReSpeaker]: +def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Optional[_ReSpeaker]: dev = usb.core.find(idVendor=vid, idProduct=pid) if not dev: return None @@ -87,16 +176,11 @@ def _find_device(vid: int = 0x2886, pid: int = 0x001A) -> Optional[_ReSpeaker]: class XVF3800LedBackend: - """High-level LED backend for XVF3800 USB LED ring. + """High-level LED backend for the XVF3800.""" - This is intentionally minimal and only covers the legacy LED controls: - - LED_EFFECT - - LED_BRIGHTNESS - - LED_SPEED - - LED_COLOR - """ + ring_led_count: int = 12 - def __init__(self, vid: int = 0x2886, pid: int = 0x001A) -> None: + def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> None: wrapper = _find_device(vid, pid) if wrapper is None: raise RuntimeError( @@ -104,18 +188,41 @@ class XVF3800LedBackend: ) self._dev = wrapper - _LOGGER.info( - "Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x)", vid, pid - ) + self.supports_per_led: bool = False + + # Best-effort feature detection: if we can read LED_RING_COLOR, we assume + # per-LED control is supported by the current firmware. + try: + _ = self._dev.read("LED_RING_COLOR") + self.supports_per_led = True + except Exception: + self.supports_per_led = False + + # Best-effort version read (not fatal) + try: + ver = self._dev.read("VERSION") + _LOGGER.info( + "Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, version=%s, per_led=%s)", + vid, + pid, + ".".join(str(int(x)) for x in ver[:3]), + self.supports_per_led, + ) + except Exception: + _LOGGER.info( + "Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, per_led=%s)", + vid, + pid, + self.supports_per_led, + ) # --------------------------------------------------------------------- - # Public high-level controls + # Legacy/global controls # --------------------------------------------------------------------- def set_effect(self, effect_id: int) -> None: """Set LED effect mode (0=off, 1=breath, 2=rainbow, 3=single color, 4=doa).""" - effect_id = int(effect_id) & 0xFF - self._dev.write("LED_EFFECT", [effect_id]) + self._dev.write("LED_EFFECT", [int(effect_id) & 0xFF]) def set_brightness(self, brightness_0_255: int) -> None: """Set LED brightness (0-255).""" @@ -124,19 +231,67 @@ class XVF3800LedBackend: def set_speed(self, speed_id: int) -> None: """Set LED effect speed (0=slow, 1=medium, 2=fast).""" - speed_id = int(speed_id) & 0xFF - self._dev.write("LED_SPEED", [speed_id]) + self._dev.write("LED_SPEED", [int(speed_id) & 0xFF]) def set_color(self, r: int, g: int, b: int) -> None: """Set LED color for breath / single color modes (0xRRGGBB).""" r = max(0, min(255, int(r))) g = max(0, min(255, int(g))) b = max(0, min(255, int(b))) - color_value = (r << 16) | (g << 8) | b self._dev.write("LED_COLOR", [color_value]) + # --------------------------------------------------------------------- + # Per-LED ring control (newer firmware) + # --------------------------------------------------------------------- + + def set_ring_colors(self, color_values: Sequence[int]) -> None: + """Set all 12 ring LEDs with 0xRRGGBB values (length must be 12).""" + if not self.supports_per_led: + raise RuntimeError("Per-LED ring control is not supported by this firmware") + if len(color_values) != self.ring_led_count: + raise ValueError( + f"LED_RING_COLOR expects {self.ring_led_count} values, got {len(color_values)}" + ) + self._dev.write("LED_RING_COLOR", [int(v) & 0xFFFFFFFF for v in color_values]) + + def set_ring_rgb(self, colors: Sequence[Tuple[int, int, int]]) -> None: + """Set all 12 ring LEDs with (r,g,b) tuples (length must be 12).""" + if len(colors) != self.ring_led_count: + raise ValueError( + f"Ring expects {self.ring_led_count} colors, got {len(colors)}" + ) + vals: List[int] = [] + for r, g, b in colors: + r = max(0, min(255, int(r))) + g = max(0, min(255, int(g))) + b = max(0, min(255, int(b))) + vals.append((r << 16) | (g << 8) | b) + self.set_ring_colors(vals) + + def set_ring_solid(self, r: int, g: int, b: int) -> None: + """Convenience: set all ring LEDs to the same RGB color.""" + self.set_ring_rgb([(r, g, b)] * self.ring_led_count) + + def clear_ring(self) -> None: + """Convenience: turn all ring LEDs off (per-LED mode).""" + if not self.supports_per_led: + # Legacy fallback: just set effect off + self.set_effect(0) + self.set_brightness(0) + return + self.set_ring_colors([0] * self.ring_led_count) + + def get_version(self) -> Optional[Tuple[int, int, int]]: + """Return (major, minor, patch) if readable, else None.""" + try: + ver = self._dev.read("VERSION") + if len(ver) >= 3: + return (int(ver[0]), int(ver[1]), int(ver[2])) + except Exception: + return None + return None + def close(self) -> None: if self._dev is not None: self._dev.close() - From 5932f945e9d8f5e63c9c244f4f50fcae413d9438 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Fri, 2 Jan 2026 19:03:17 -0800 Subject: [PATCH 34/59] Update linux-voice-assistant.service Add require for pipewire --- service/linux-voice-assistant.service | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service/linux-voice-assistant.service b/service/linux-voice-assistant.service index 5c95bb9..9d3b7a3 100644 --- a/service/linux-voice-assistant.service +++ b/service/linux-voice-assistant.service @@ -1,5 +1,7 @@ [Unit] Description=Linux Voice Assistant +Wants=pipewire.service wireplumber.service pipewire-pulse.service +After=pipewire.service wireplumber.service pipewire-pulse.service [Service] Type=simple From 3ba4e748ee09c9fb5117676befba2f89dd6219c3 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:56:37 -0800 Subject: [PATCH 35/59] Update __main__.py Adds XVF3800-specific initialization flow during controller startup. Ensures the XVF3800 preflight runs at the right time so audio initialization sees the correct device state. --- linux_voice_assistant/__main__.py | 107 +++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 20b723e..fcb0c46 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -3,6 +3,8 @@ import argparse import asyncio import json import logging +import os +import time import sys from dataclasses import dataclass from pathlib import Path @@ -151,6 +153,9 @@ async def main() -> None: # --- 2. Load Preferences --- preferences = _load_preferences(config) + # --- 2b. XVF3800 Startup Workarounds (optional) --- + _xvf3800_startup_preflight(config) + # --- 3. Find Microphone --- mic = _get_microphone(config) @@ -264,15 +269,111 @@ def _load_preferences(config: Config) -> Preferences: return preferences + +def _xvf3800_startup_preflight(config: Config) -> None: + """ + Best-effort XVF3800 USB preflight. + + Some platforms (notably certain SBC USB controllers) can leave the XVF3800 + capture endpoint "silent" until the device is rebooted. + + If an XVF3800 is configured (LED/button/audio), we optionally: + 1) issue an XVF3800 REBOOT + 2) wait for USB re-enumeration + 3) set AUDIO_MGR_OP_L and AUDIO_MGR_OP_R to (7, 3) to force both channels + to the ASR3 output (as discovered via xvf_host.py testing) + + This uses PyUSB directly (no dependency on xvf_host.py). + """ + try: + # Determine whether XVF3800 is in use at all + led_cfg = getattr(config, "led", None) + btn_cfg = getattr(config, "button", None) + aud_cfg = getattr(config, "audio", None) + + uses_xvf = False + if led_cfg and getattr(led_cfg, "led_type", "").lower() == "xvf3800": + uses_xvf = True + if btn_cfg and getattr(btn_cfg, "enabled", False) and getattr(btn_cfg, "mode", "").lower() == "xvf3800": + uses_xvf = True + if aud_cfg and isinstance(getattr(aud_cfg, "input_device", None), str) and "xvf3800" in aud_cfg.input_device.lower(): + uses_xvf = True + + if not uses_xvf: + return + + # Env toggles (default enabled) + do_reboot = os.environ.get("LVA_XVF3800_STARTUP_REBOOT", "1").strip().lower() not in ("0", "false", "no", "off") + do_route = os.environ.get("LVA_XVF3800_STARTUP_SET_ASR3", "1").strip().lower() not in ("0", "false", "no", "off") + do_save = os.environ.get("LVA_XVF3800_STARTUP_SAVE_CONFIG", "0").strip().lower() in ("1", "true", "yes", "on") + + if not (do_reboot or do_route): + return + + from .xvf3800_led_backend import XVF3800USBDevice + + _LOGGER.info("XVF3800 startup preflight: begin (reboot=%s, set_asr3=%s, save=%s)", do_reboot, do_route, do_save) + + if do_reboot: + try: + dev = XVF3800USBDevice() + _LOGGER.info("XVF3800 startup preflight: issuing REBOOT to USB device") + dev.reboot() + finally: + try: + dev.close() + except Exception: + pass + + # Wait for USB to cycle + XVF3800USBDevice.wait_for_reenumeration(timeout_s=12.0, settle_s=1.0) + + if do_route: + dev2 = XVF3800USBDevice() + try: + _LOGGER.info("XVF3800 startup preflight: setting AUDIO_MGR_OP_L and AUDIO_MGR_OP_R to (7, 3)") + dev2.set_audio_mgr_op_l(7, 3) + dev2.set_audio_mgr_op_r(7, 3) + if do_save: + _LOGGER.info("XVF3800 startup preflight: saving configuration to flash") + dev2.save_configuration() + finally: + dev2.close() + + _LOGGER.info("XVF3800 startup preflight: done") + + except Exception as e: + _LOGGER.warning("XVF3800 startup preflight failed (continuing): %s", e) + + + def _get_microphone(config: Config): """Finds and returns the microphone specified in the config.""" mic = None - if config.audio.input_device is not None: + input_spec = getattr(config.audio, "input_device", None) + + # If the user specified an index, honor it. + if input_spec is not None: try: - input_device_idx = int(config.audio.input_device) + input_device_idx = int(input_spec) mic = sc.all_microphones(include_loopback=False)[input_device_idx] except (ValueError, IndexError): - mic = sc.get_microphone(config.audio.input_device, include_loopback=False) + # If the user specified a name, retry a bit (helps after USB re-enumeration / slow PipeWire startup). + want_name = str(input_spec) + is_xvf = "xvf3800" in want_name.lower() + deadline = time.time() + (20.0 if is_xvf else 5.0) + + last_err: Optional[Exception] = None + while time.time() < deadline: + try: + mic = sc.get_microphone(want_name, include_loopback=False) + break + except Exception as e: + last_err = e + time.sleep(0.5) + + if mic is None and last_err is not None: + _LOGGER.warning("Failed to open configured mic %r after retries: %s", want_name, last_err) else: mic = sc.default_microphone() From b8a3ce2c39eabdb9b58bc34f2e4529cd116b888e Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:57:52 -0800 Subject: [PATCH 36/59] Update xvf3800_led_backend.py Extends the existing XVF3800 USB control backend beyond LEDs to support startup preflight commands needed for audio reliability. Implements command execution internally (no reliance on xvf_host.py) using the same USB control-transfer approach already used for LED features. --- linux_voice_assistant/xvf3800_led_backend.py | 80 ++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/linux_voice_assistant/xvf3800_led_backend.py b/linux_voice_assistant/xvf3800_led_backend.py index f3a9469..31bfd81 100644 --- a/linux_voice_assistant/xvf3800_led_backend.py +++ b/linux_voice_assistant/xvf3800_led_backend.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging import struct +import time from typing import List, Optional, Sequence, Tuple import usb.core # type: ignore[import] @@ -33,6 +34,14 @@ PARAMETERS = { # --------------------------------------------------------------------- "VERSION": (48, 0, 3, "ro", "uint8"), + # Firmware / device control + "REBOOT": (48, 7, 1, "wo", "uint8"), + "SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8"), + + # Audio manager output routing (category, source) per channel + "AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8"), + "AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8"), + # --------------------------------------------------------------------- # GPO_SERVICER_RESID (LED controls) # --------------------------------------------------------------------- @@ -58,6 +67,14 @@ class _ReSpeaker: def __init__(self, dev: "usb.core.Device") -> None: # type: ignore[name-defined] self.dev = dev + def close(self) -> None: + """Release any underlying libusb resources (best-effort).""" + try: + usb.util.dispose_resources(self.dev) + except Exception: + pass + + # ------------------------------------------------------------------ # Encoding / decoding helpers # ------------------------------------------------------------------ @@ -175,6 +192,69 @@ def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Option return _ReSpeaker(dev) + +class XVF3800USBDevice: + """Small helper for issuing non-LED XVF3800 control commands via PyUSB. + + This is intentionally minimal and does *not* depend on xvf_host.py. + """ + + def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID): + self._rsp = _find_device(vid=vid, pid=pid) + + def close(self) -> None: + try: + self._rsp.close() + except Exception: + pass + + # --- Device control ----------------------------------------------------- + + def reboot(self) -> None: + """Reboot the XVF3800 firmware (resets parameters to defaults).""" + self._rsp.write("REBOOT", [1]) + + def save_configuration(self) -> None: + """Persist current runtime settings to flash.""" + self._rsp.write("SAVE_CONFIGURATION", [1]) + + # --- Audio routing ------------------------------------------------------ + + def set_audio_mgr_op_l(self, category: int, source: int) -> None: + """Set the L output channel source selection (category, source).""" + self._rsp.write("AUDIO_MGR_OP_L", [int(category) & 0xFF, int(source) & 0xFF]) + + def set_audio_mgr_op_r(self, category: int, source: int) -> None: + """Set the R output channel source selection (category, source).""" + self._rsp.write("AUDIO_MGR_OP_R", [int(category) & 0xFF, int(source) & 0xFF]) + + # --- Wait helpers ------------------------------------------------------- + + @staticmethod + def wait_for_reenumeration( + vid: int = _ReSpeaker.VID, + pid: int = _ReSpeaker.PID, + timeout_s: float = 12.0, + settle_s: float = 1.0, + ) -> None: + """Wait for the XVF3800 to disappear and then reappear on USB.""" + start = time.time() + # Wait for it to disappear (best effort) + while time.time() - start < timeout_s: + if usb.core.find(idVendor=vid, idProduct=pid) is None: + break + time.sleep(0.1) + + # Wait for it to reappear + while time.time() - start < timeout_s: + if usb.core.find(idVendor=vid, idProduct=pid) is not None: + break + time.sleep(0.1) + + # Give kernel/userspace audio stack a moment to settle + if settle_s > 0: + time.sleep(settle_s) + class XVF3800LedBackend: """High-level LED backend for the XVF3800.""" From 0f3c7d6c15da65d7168bac990cc6867f4f68e1da Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:59:13 -0800 Subject: [PATCH 37/59] Update led_controller.py Updated to align with the new XVF3800 initialization pathway / preflight behavior. Keeps XVF3800 behavior isolated to XVF3800 mode so other LED backends remain unchanged. From a1b460391de416966328448faa584fd1df6830b4 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:46:15 -0800 Subject: [PATCH 38/59] Add audio_volume.py This helper script keeps the LVA media player volume and the OS device volume synchronized. --- linux_voice_assistant/audio_volume.py | 149 ++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 linux_voice_assistant/audio_volume.py diff --git a/linux_voice_assistant/audio_volume.py b/linux_voice_assistant/audio_volume.py new file mode 100644 index 0000000..a5f3a7e --- /dev/null +++ b/linux_voice_assistant/audio_volume.py @@ -0,0 +1,149 @@ +"""System output volume helpers. + +LVA historically only adjusted mpv's per-player volume. On PipeWire/PulseAudio +setups where the default sink volume starts low (e.g. 40%), HA shows LVA at +100% while the actual OS sink remains capped. + +This module keeps the OS output volume aligned with LVA's persisted +preferences.volume_level (0.0–1.0). + +We try backends in this order: + 1) PipeWire: wpctl + 2) PulseAudio (incl. pipewire-pulse): pactl + 3) ALSA: amixer +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import shutil +import subprocess +from typing import Optional + +_LOGGER = logging.getLogger(__name__) + + +def _clamp01(v: float) -> float: + try: + v = float(v) + except Exception: + return 1.0 + if math.isnan(v) or math.isinf(v): + return 1.0 + return max(0.0, min(1.0, v)) + + +def _pactl_sink_from_output_device(output_device: Optional[str]) -> str: + """Best-effort mapping of mpv audio-device -> pactl sink name. + +LVA commonly uses: + - "pipewire/" + - "pulse/" + - "alsa_output...." (already a sink name) + +Fallback is @DEFAULT_SINK@. +""" + if not output_device: + return "@DEFAULT_SINK@" + + dev = output_device.strip() + + for prefix in ("pipewire/", "pulse/"): + if dev.startswith(prefix) and len(dev) > len(prefix): + return dev[len(prefix) :] + + if dev.startswith("alsa_output."): + return dev + + # Some users pass the raw sink name without a prefix. + if "alsa_output." in dev: + return dev + + return "@DEFAULT_SINK@" + + +def _run_cmd(cmd: list[str], timeout_s: float = 2.0) -> tuple[bool, str]: + try: + proc = subprocess.run( + cmd, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout_s, + ) + ok = proc.returncode == 0 + out = (proc.stdout or "").strip() + return ok, out + except FileNotFoundError: + return False, f"command not found: {cmd[0]}" + except subprocess.TimeoutExpired: + return False, "timeout" + except Exception as e: + return False, repr(e) + + +def set_output_volume( + volume_0_1: float, + output_device: Optional[str] = None, + logger: logging.Logger = _LOGGER, +) -> bool: + """Set OS output volume to match LVA volume (0.0–1.0). + +Returns True if any backend succeeded. +""" + vol = _clamp01(volume_0_1) + + # --- 1) PipeWire: wpctl ------------------------------------------------- + if shutil.which("wpctl"): + # wpctl accepts @DEFAULT_AUDIO_SINK@ and a linear factor (e.g. 0.40) + ok, out = _run_cmd(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{vol:.3f}"]) + if ok: + logger.debug("Set PipeWire sink volume via wpctl: %.3f", vol) + return True + logger.debug("wpctl set-volume failed: %s", out) + + # --- 2) PulseAudio: pactl ---------------------------------------------- + if shutil.which("pactl"): + sink = _pactl_sink_from_output_device(output_device) + pct = int(round(vol * 100.0)) + ok, out = _run_cmd(["pactl", "set-sink-volume", sink, f"{pct}%"]) + if ok: + logger.debug("Set Pulse sink volume via pactl: sink=%s pct=%d", sink, pct) + return True + logger.debug("pactl set-sink-volume failed: %s", out) + + # --- 3) ALSA: amixer ---------------------------------------------------- + if shutil.which("amixer"): + pct = int(round(vol * 100.0)) + # Common mixer controls across SBC images. + for control in ("Master", "PCM", "Speaker", "Headphone"): + ok, out = _run_cmd(["amixer", "-q", "sset", control, f"{pct}%"]) + if ok: + logger.debug("Set ALSA volume via amixer: control=%s pct=%d", control, pct) + return True + logger.debug("amixer sset %s failed: %s", control, out) + + return False + + +async def ensure_output_volume( + volume: float, + output_device: Optional[str] = None, + attempts: int = 10, + delay_seconds: float = 0.5, + logger: logging.Logger = _LOGGER, +) -> bool: + """Retry output volume sync during startup. + +Useful at boot when PipeWire/Pulse/ALSA might not be fully ready yet. +""" + for i in range(1, max(1, attempts) + 1): + ok = await asyncio.to_thread(set_output_volume, volume, output_device, logger) + if ok: + return True + if i < attempts: + await asyncio.sleep(delay_seconds) + return False From eb4c4c26c8fba8460934e12bf791c27a8bec850d Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:51:21 -0800 Subject: [PATCH 39/59] Update config.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What this fixes: On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0). When you move the HA media_player volume slider, LVA now updates: wpctl (PipeWire) if available pactl (PulseAudio / PipeWire-Pulse) if available amixer (ALSA) as a fallback No reboot / alsactl store required — LVA simply re-applies the persisted volume at start. --- linux_voice_assistant/config.py | 274 +++++++++++++++----------------- 1 file changed, 132 insertions(+), 142 deletions(-) diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py index cde815c..61a207a 100644 --- a/linux_voice_assistant/config.py +++ b/linux_voice_assistant/config.py @@ -1,161 +1,151 @@ -"""Configuration models for the application.""" +from abc import abstractmethod +from collections.abc import Iterable +from typing import TYPE_CHECKING, Callable, List, Optional, Union -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional +# pylint: disable=no-name-in-module +from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] + ListEntitiesMediaPlayerResponse, + ListEntitiesRequest, + MediaPlayerCommandRequest, + MediaPlayerStateResponse, + SubscribeHomeAssistantStatesRequest, +) +from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState +from google.protobuf import message + +from .api_server import APIServer +from .mpv_player import MpvMediaPlayer +from .util import call_all + +if TYPE_CHECKING: + from .models import ServerState + + +class ESPHomeEntity: + def __init__(self, server: APIServer, state: "ServerState") -> None: + self.server = server + self.state = state + + @abstractmethod + def handle_message(self, msg: message.Message) -> Iterable[message.Message]: + pass -import logging -_LOGGER = logging.getLogger(__name__) # ----------------------------------------------------------------------------- -# Configuration Dataclasses -# ----------------------------------------------------------------------------- - -@dataclass -class AudioConfig: - """Settings for audio input and output.""" - input_device: Optional[str] = None - input_block_size: int = 1024 - output_device: Optional[str] = None -@dataclass -class WakeWordConfig: - """Settings for wake word detection.""" - directories: List[str] = field(default_factory=list) - model: str = "okay_nabu" - stop_model: str = "stop" - refractory_seconds: float = 2.0 - download_dir: str = "local" +class MediaPlayerEntity(ESPHomeEntity): + def __init__( + self, + server: APIServer, + state: "ServerState", + key: int, + name: str, + object_id: str, + music_player: MpvMediaPlayer, + announce_player: MpvMediaPlayer, + ) -> None: + super().__init__(server, state) + self.key = key + self.name = name + self.object_id = object_id + self.state_enum = MediaPlayerState.IDLE + self.volume = state.preferences.volume_level # Initialize with saved volume + self.muted = False + self.music_player = music_player + self.announce_player = announce_player -@dataclass -class ESPHomeConfig: - """Settings for the ESPHome API server.""" - host: str = "0.0.0.0" - port: int = 6053 + def play( + self, + url: Union[str, List[str]], + announcement: bool = False, + done_callback: Optional[Callable[[], None]] = None, + ) -> Iterable[message.Message]: + if announcement: + if self.music_player.is_playing: + # Announce, resume music + self.music_player.pause() + self.announce_player.play( + url, + done_callback=lambda: call_all( + self.music_player.resume, done_callback + ), + ) + else: + # Announce, idle + self.announce_player.play( + url, + done_callback=lambda: call_all( + self.server.send_messages( + [self._update_state(MediaPlayerState.IDLE)] + ), + done_callback, + ), + ) + else: + # Music + self.music_player.play( + url, + done_callback=lambda: call_all( + self.server.send_messages( + [self._update_state(MediaPlayerState.IDLE)] + ), + done_callback, + ), + ) + yield self._update_state(MediaPlayerState.PLAYING) -@dataclass -class LedConfig: - """Settings for the LED strip. + def handle_message(self, msg: message.Message) -> Iterable[message.Message]: + if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key): + if msg.has_media_url: + announcement = msg.has_announcement and msg.announcement + yield from self.play(msg.media_url, announcement=announcement) + elif msg.has_command: + if msg.command == MediaPlayerCommand.PAUSE: + self.music_player.pause() + yield self._update_state(MediaPlayerState.PAUSED) + elif msg.command == MediaPlayerCommand.PLAY: + self.music_player.resume() + yield self._update_state(MediaPlayerState.PLAYING) - Fields: - enabled -> master on/off for hardware LEDs. If False, LedController - still subscribes to events and publishes MQTT state, but - never touches hardware. - led_type -> "dotstar", "neopixel", or "xvf3800" - interface -> "spi", "gpio", "usb", or "i2c" (future) - """ - enabled: bool = True - led_type: str = "dotstar" - interface: str = "spi" - clock_pin: int = 13 - data_pin: int = 12 - # Note: overridden by 'preferences.json' if it exists - num_leds: int = 3 + if msg.has_volume: + # HA sends volume as 0.0-1.0. We treat this as the *master* + # output volume and sync it to the OS audio backend. + self.volume = msg.volume + # Keep mpv at 100% for normal playback; ducking uses mpv volume. + self.music_player.set_volume(100) + self.announce_player.set_volume(100) -@dataclass -class MqttConfig: - """Settings for the MQTT client.""" - enabled: bool = False - host: Optional[str] = None - port: int = 1883 - username: Optional[str] = None - password: Optional[str] = None + # Apply master volume to the system sink (PipeWire/Pulse/ALSA). + self.music_player.set_master_volume(self.volume) + # Save the new volume level to preferences + self.state.preferences.volume_level = self.volume + self.state.save_preferences() -@dataclass -class ButtonConfig: - """ - Settings for a hardware momentary button. + yield self._update_state(self.state_enum) - mode: - - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT) - - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800 + elif isinstance(msg, ListEntitiesRequest): + yield ListEntitiesMediaPlayerResponse( + object_id=self.object_id, + key=self.key, + name=self.name, + supports_pause=True, + ) + elif isinstance(msg, SubscribeHomeAssistantStatesRequest): + yield self._get_state_message() - For mode="gpio": - - 'pin' and 'long_press_seconds' are used (short vs long press). - For mode="xvf3800": - - The XVF3800ButtonController uses the built-in mute button purely - as a mute toggle; 'pin' and 'long_press_seconds' are ignored. - """ - enabled: bool = False - mode: str = "gpio" # "gpio" or "xvf3800" - # BCM GPIO pin number for the button input (gpio mode only). - pin: int = 17 - # Press duration (in seconds) to be considered a "long press" (gpio mode). - long_press_seconds: float = 1.0 - # Poll interval used by both GPIO and XVF3800 controllers. - poll_interval_seconds: float = 0.01 + def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse: + self.state_enum = new_state + return self._get_state_message() - -@dataclass -class AppConfig: - """General application settings.""" - name: str - wakeup_sound: str = "sounds/wake_word_triggered.flac" - timer_finished_sound: str = "sounds/timer_finished.flac" - preferences_file: str = "preferences.json" - debug: bool = False - - -@dataclass -class Config: - """Main configuration object.""" - app: AppConfig - audio: AudioConfig = field(default_factory=AudioConfig) - wake_word: WakeWordConfig = field(default_factory=WakeWordConfig) - 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 -# ----------------------------------------------------------------------------- - -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: - raw_data = json.load(f) - except FileNotFoundError: - _LOGGER.critical(f"Configuration file not found at: {config_path}") - raise - except json.JSONDecodeError as e: - _LOGGER.critical(f"Error parsing configuration file: {e}") - raise - - # --- 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'." + def _get_state_message(self) -> MediaPlayerStateResponse: + return MediaPlayerStateResponse( + key=self.key, + state=self.state_enum, + volume=self.volume, + muted=self.muted, ) - - 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 - - # --- Step 4: Return the main Config object --- - return Config( - app=app_config, - audio=audio_config, - wake_word=wake_word_config, - esphome=esphome_config, - led=led_config, - mqtt=mqtt_config, - button=button_config, - ) From 8a9939cf625c81eb38703d58661a639ecac3d836 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:52:02 -0800 Subject: [PATCH 40/59] Update __main__.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What this fixes: On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0). When you move the HA media_player volume slider, LVA now updates: wpctl (PipeWire) if available pactl (PulseAudio / PipeWire-Pulse) if available amixer (ALSA) as a fallback No reboot / alsactl store required — LVA simply re-applies the persisted volume at start. --- linux_voice_assistant/__main__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index fcb0c46..2d95fba 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -24,6 +24,7 @@ from .led_controller import LedController from .mqtt_controller import MqttController from .models import AvailableWakeWord, Preferences, ServerState, WakeWordType from .mpv_player import MpvMediaPlayer +from .audio_volume import ensure_output_volume from .satellite import VoiceSatelliteProtocol from .util import get_mac_address, format_mac from .zeroconf import HomeAssistantZeroconf @@ -165,6 +166,21 @@ async def main() -> None: # --- 5. Initialize Media Players --- media_players = _init_media_players(loop, config, preferences) + # --- 5b. Sync OS sink volume to persisted volume --- + # mpv's per-player volume is kept at 100% and ducking is handled within mpv. + # The user-visible "volume" in HA maps to the OS output volume (PipeWire/Pulse/ALSA). + try: + loop.create_task( + ensure_output_volume( + volume=preferences.volume_level, + output_device=config.audio.output_device, + attempts=20, + delay_seconds=0.5, + ) + ) + except Exception: + _LOGGER.exception("Failed to schedule output volume sync") + # --- 6. Create Server State --- state = _create_server_state( config, loop, event_bus, preferences, From 4da78c74359c7604d86c5bbce741a294252167ff Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:52:41 -0800 Subject: [PATCH 41/59] Update mpv_player.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What this fixes: On boot, if PipeWire/Pulse starts with the sink at ~40%, LVA now pushes the sink volume to match the persisted LVA volume (0.0–1.0). When you move the HA media_player volume slider, LVA now updates: wpctl (PipeWire) if available pactl (PulseAudio / PipeWire-Pulse) if available amixer (ALSA) as a fallback No reboot / alsactl store required — LVA simply re-applies the persisted volume at start. --- linux_voice_assistant/mpv_player.py | 146 +++++++++++++--------------- 1 file changed, 69 insertions(+), 77 deletions(-) diff --git a/linux_voice_assistant/mpv_player.py b/linux_voice_assistant/mpv_player.py index 2bf7afd..abc7eab 100644 --- a/linux_voice_assistant/mpv_player.py +++ b/linux_voice_assistant/mpv_player.py @@ -1,5 +1,4 @@ -""" -Media player using mpv in a subprocess. +"""Media player using mpv in a subprocess. This wrapper focuses on: - Simple playback control (play / pause / resume / stop) @@ -9,7 +8,17 @@ This wrapper focuses on: If a specific audio device is provided, it is passed directly to mpv as `audio-device`. Otherwise, mpv's own automatic backend/device selection is used. + +Note about volume: +- mpv has its own per-player volume (0..100). +- PipeWire/PulseAudio/ALSA also has a system output volume. + +LVA persists a single user volume (0.0..1.0) in preferences.json. To avoid +"mystery caps" where the OS sink is stuck at e.g. 40% while LVA shows 100%, +LVA treats the OS output volume as the "master" and keeps mpv at 100% for normal +playback. Ducking still uses mpv's per-player volume. """ + from __future__ import annotations import asyncio @@ -21,6 +30,8 @@ from typing import Callable, List, Optional, Sequence, Union # Note: python-mpv must be installed; imported at runtime. from mpv import MPV +from .audio_volume import set_output_volume + _LOGGER = logging.getLogger(__name__) @@ -33,13 +44,19 @@ class MpvMediaPlayer: device: Optional[str] = None, initial_volume: float = 1.0, ) -> None: - """ + """Initialize the mpv player. + :param loop: The asyncio loop used to schedule done_callback. May be None in one-off utility contexts (e.g. listing devices). - :param device: Optional mpv audio device name (e.g. "pulse/alsa_output.pci-0000_00_1f.3.analog-stereo"). - :param initial_volume: Initial volume as a float 0.0–1.0. + :param device: Optional mpv audio device name (e.g. + "pipewire/alsa_output.pci-0000_00_1f.3.analog-stereo"). + :param initial_volume: Initial volume as a float 0.0–1.0. This is now + treated as the *master* OS output volume. mpv's + internal volume is set to 100% for normal playback. """ self.loop = loop + self.device = device + self.initial_volume = max(0.0, min(1.0, float(initial_volume))) self.player = MPV( video=False, @@ -83,8 +100,7 @@ class MpvMediaPlayer: try: dev_list = self.player.audio_device_list or [] dev_summary = [ - f"{dev.get('name')} ({dev.get('description')})" - for dev in dev_list + f"{dev.get('name')} ({dev.get('description')})" for dev in dev_list ] except Exception: dev_summary = [""] @@ -98,15 +114,12 @@ class MpvMediaPlayer: except Exception: _LOGGER.exception("Failed to query mpv audio properties") - # Volume is 0–100 in mpv, we accept 0.0–1.0 here. - self.set_volume(int(initial_volume * 100)) + # Keep mpv at 100% for normal playback. Master volume is handled at the OS level. + self.set_volume(100) self.is_playing: bool = False - - # One callback per "logical playback session" (music or announce) self._done_callback: Optional[Callable[[], None]] = None self._done_callback_lock = Lock() - self._pre_duck_volume: Optional[int] = None # When mpv becomes idle, we treat it as end-of-playback. @@ -126,11 +139,11 @@ class MpvMediaPlayer: :param url: A single URL (str/bytes) or a sequence of URLs. :param done_callback: Called once when playback finishes or is stopped. """ - # IMPORTANT: - # When starting a new playback session (e.g., Music Assistant "next track"), - # we must NOT fire the previous session's done_callback (it would set HA to IDLE - # even though a new track is starting). - self.stop(run_done_callback=False) + # Ensure player is in a clean state + self.stop() + + with self._done_callback_lock: + self._done_callback = done_callback playlist: List[str] = [] if isinstance(url, (list, tuple)): @@ -141,35 +154,20 @@ class MpvMediaPlayer: playlist = [url] else: _LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url)) - if done_callback: - self._call_done_callback(done_callback) + self._run_done_callback() return if not playlist: - if done_callback: - self._call_done_callback(done_callback) + self._run_done_callback() return - with self._done_callback_lock: - self._done_callback = done_callback - # Load the full playlist into mpv - try: - self.player.playlist_clear() - for item in playlist: - self.player.playlist_append(item) - self.player.playlist_pos = 0 # Start playing from the first item - self.is_playing = True - except Exception: - _LOGGER.exception("Failed to start playback") - self.is_playing = False - # pop callback and run it, since we failed to start - cb = None - with self._done_callback_lock: - cb = self._done_callback - self._done_callback = None - if cb: - self._call_done_callback(cb) + self.player.playlist_clear() + for item in playlist: + self.player.playlist_append(item) + + self.is_playing = True + self.player.playlist_pos = 0 # Start playing from the first item def pause(self) -> None: """Pauses playback.""" @@ -185,43 +183,35 @@ class MpvMediaPlayer: except Exception: _LOGGER.exception("resume() failed") - def stop(self, run_done_callback: bool = True) -> None: - """Stops playback and clears the playlist. - - :param run_done_callback: If False, clears any prior done_callback without running it. - This is critical when replacing one track with another. - """ - # Always clear the stored callback so an "old session" callback can't fire later. - cb: Optional[Callable[[], None]] = None - with self._done_callback_lock: - if run_done_callback: - cb = self._done_callback - self._done_callback = None - - was_playing = self.is_playing - self.is_playing = False - - # Best-effort stop. Even if mpv throws, we still want callback behavior. - try: - self.player.playlist_clear() - self.player.command("stop") - except Exception: - # If this was an external stop request, log it; otherwise keep quiet. - if was_playing: + def stop(self) -> None: + """Stops playback and clears the playlist.""" + if self.is_playing: + self.is_playing = False + try: + self.player.playlist_clear() + self.player.command("stop") + except Exception: _LOGGER.exception("stop() failed") - - if cb: - self._call_done_callback(cb) + finally: + self._run_done_callback() def set_volume(self, volume: int) -> None: - """Sets the player volume from 0 to 100.""" + """Sets the player (mpv) volume from 0 to 100.""" try: self.player.volume = max(0, min(100, volume)) except Exception: _LOGGER.exception("set_volume() failed") + def set_master_volume(self, volume_level: float) -> bool: + """Sets the *OS output* volume (PipeWire/PulseAudio/ALSA). + + :param volume_level: 0.0–1.0 + :returns: True if any backend succeeded. + """ + return set_output_volume(volume_level=volume_level, output_device=self.device) + def duck(self, target_percent: int = 20) -> None: - """Lowers the volume for an announcement.""" + """Lowers the mpv volume for an announcement.""" if self._pre_duck_volume is not None: return try: @@ -231,7 +221,7 @@ class MpvMediaPlayer: _LOGGER.exception("duck() failed") def unduck(self) -> None: - """Restores the volume after an announcement.""" + """Restores the mpv volume after an announcement.""" if self._pre_duck_volume is None: return try: @@ -250,23 +240,25 @@ class MpvMediaPlayer: if active and self.is_playing: _LOGGER.debug("mpv became idle; treating as end-of-playback") self.is_playing = False + self._run_done_callback() - cb = None - with self._done_callback_lock: - cb = self._done_callback - self._done_callback = None + def _run_done_callback(self) -> None: + """Safely runs the done_callback on the main asyncio loop (if any).""" + with self._done_callback_lock: + cb = self._done_callback + self._done_callback = None - if cb: - self._call_done_callback(cb) + if not cb: + return - def _call_done_callback(self, cb: Callable[[], None]) -> None: - """Runs a done_callback on the main asyncio loop (if any).""" + # If we have an asyncio loop, schedule callback there. if self.loop is not None: try: self.loop.call_soon_threadsafe(cb) except Exception: _LOGGER.exception("Error scheduling done_callback on loop") else: + # Fallback: call directly (used in contexts where no loop is passed). try: cb() except Exception: From cfc24f6b73a2af36fefd5bca1fc598541f137453 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:03:44 -0800 Subject: [PATCH 42/59] Update config.py Fix self shot foot. --- linux_voice_assistant/config.py | 274 +++++++++++++++++--------------- 1 file changed, 142 insertions(+), 132 deletions(-) diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py index 61a207a..cde815c 100644 --- a/linux_voice_assistant/config.py +++ b/linux_voice_assistant/config.py @@ -1,151 +1,161 @@ -from abc import abstractmethod -from collections.abc import Iterable -from typing import TYPE_CHECKING, Callable, List, Optional, Union +"""Configuration models for the application.""" -# pylint: disable=no-name-in-module -from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined] - ListEntitiesMediaPlayerResponse, - ListEntitiesRequest, - MediaPlayerCommandRequest, - MediaPlayerStateResponse, - SubscribeHomeAssistantStatesRequest, -) -from aioesphomeapi.model import MediaPlayerCommand, MediaPlayerState -from google.protobuf import message - -from .api_server import APIServer -from .mpv_player import MpvMediaPlayer -from .util import call_all - -if TYPE_CHECKING: - from .models import ServerState - - -class ESPHomeEntity: - def __init__(self, server: APIServer, state: "ServerState") -> None: - self.server = server - self.state = state - - @abstractmethod - def handle_message(self, msg: message.Message) -> Iterable[message.Message]: - pass +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional +import logging +_LOGGER = logging.getLogger(__name__) # ----------------------------------------------------------------------------- +# Configuration Dataclasses +# ----------------------------------------------------------------------------- + +@dataclass +class AudioConfig: + """Settings for audio input and output.""" + input_device: Optional[str] = None + input_block_size: int = 1024 + output_device: Optional[str] = None -class MediaPlayerEntity(ESPHomeEntity): - def __init__( - self, - server: APIServer, - state: "ServerState", - key: int, - name: str, - object_id: str, - music_player: MpvMediaPlayer, - announce_player: MpvMediaPlayer, - ) -> None: - super().__init__(server, state) +@dataclass +class WakeWordConfig: + """Settings for wake word detection.""" + directories: List[str] = field(default_factory=list) + model: str = "okay_nabu" + stop_model: str = "stop" + refractory_seconds: float = 2.0 + download_dir: str = "local" - self.key = key - self.name = name - self.object_id = object_id - self.state_enum = MediaPlayerState.IDLE - self.volume = state.preferences.volume_level # Initialize with saved volume - self.muted = False - self.music_player = music_player - self.announce_player = announce_player - def play( - self, - url: Union[str, List[str]], - announcement: bool = False, - done_callback: Optional[Callable[[], None]] = None, - ) -> Iterable[message.Message]: - if announcement: - if self.music_player.is_playing: - # Announce, resume music - self.music_player.pause() - self.announce_player.play( - url, - done_callback=lambda: call_all( - self.music_player.resume, done_callback - ), - ) - else: - # Announce, idle - self.announce_player.play( - url, - done_callback=lambda: call_all( - self.server.send_messages( - [self._update_state(MediaPlayerState.IDLE)] - ), - done_callback, - ), - ) - else: - # Music - self.music_player.play( - url, - done_callback=lambda: call_all( - self.server.send_messages( - [self._update_state(MediaPlayerState.IDLE)] - ), - done_callback, - ), - ) +@dataclass +class ESPHomeConfig: + """Settings for the ESPHome API server.""" + host: str = "0.0.0.0" + port: int = 6053 - yield self._update_state(MediaPlayerState.PLAYING) - def handle_message(self, msg: message.Message) -> Iterable[message.Message]: - if isinstance(msg, MediaPlayerCommandRequest) and (msg.key == self.key): - if msg.has_media_url: - announcement = msg.has_announcement and msg.announcement - yield from self.play(msg.media_url, announcement=announcement) - elif msg.has_command: - if msg.command == MediaPlayerCommand.PAUSE: - self.music_player.pause() - yield self._update_state(MediaPlayerState.PAUSED) - elif msg.command == MediaPlayerCommand.PLAY: - self.music_player.resume() - yield self._update_state(MediaPlayerState.PLAYING) +@dataclass +class LedConfig: + """Settings for the LED strip. - if msg.has_volume: - # HA sends volume as 0.0-1.0. We treat this as the *master* - # output volume and sync it to the OS audio backend. - self.volume = msg.volume + Fields: + enabled -> master on/off for hardware LEDs. If False, LedController + still subscribes to events and publishes MQTT state, but + never touches hardware. + led_type -> "dotstar", "neopixel", or "xvf3800" + interface -> "spi", "gpio", "usb", or "i2c" (future) + """ + enabled: bool = True + led_type: str = "dotstar" + interface: str = "spi" + clock_pin: int = 13 + data_pin: int = 12 + # Note: overridden by 'preferences.json' if it exists + num_leds: int = 3 - # Keep mpv at 100% for normal playback; ducking uses mpv volume. - self.music_player.set_volume(100) - self.announce_player.set_volume(100) - # Apply master volume to the system sink (PipeWire/Pulse/ALSA). - self.music_player.set_master_volume(self.volume) +@dataclass +class MqttConfig: + """Settings for the MQTT client.""" + enabled: bool = False + host: Optional[str] = None + port: int = 1883 + username: Optional[str] = None + password: Optional[str] = None - # Save the new volume level to preferences - self.state.preferences.volume_level = self.volume - self.state.save_preferences() - yield self._update_state(self.state_enum) +@dataclass +class ButtonConfig: + """ + Settings for a hardware momentary button. - elif isinstance(msg, ListEntitiesRequest): - yield ListEntitiesMediaPlayerResponse( - object_id=self.object_id, - key=self.key, - name=self.name, - supports_pause=True, - ) - elif isinstance(msg, SubscribeHomeAssistantStatesRequest): - yield self._get_state_message() + mode: + - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT) + - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800 - def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse: - self.state_enum = new_state - return self._get_state_message() + For mode="gpio": + - 'pin' and 'long_press_seconds' are used (short vs long press). + For mode="xvf3800": + - The XVF3800ButtonController uses the built-in mute button purely + as a mute toggle; 'pin' and 'long_press_seconds' are ignored. + """ + enabled: bool = False + mode: str = "gpio" # "gpio" or "xvf3800" + # BCM GPIO pin number for the button input (gpio mode only). + pin: int = 17 + # Press duration (in seconds) to be considered a "long press" (gpio mode). + long_press_seconds: float = 1.0 + # Poll interval used by both GPIO and XVF3800 controllers. + poll_interval_seconds: float = 0.01 - def _get_state_message(self) -> MediaPlayerStateResponse: - return MediaPlayerStateResponse( - key=self.key, - state=self.state_enum, - volume=self.volume, - muted=self.muted, + +@dataclass +class AppConfig: + """General application settings.""" + name: str + wakeup_sound: str = "sounds/wake_word_triggered.flac" + timer_finished_sound: str = "sounds/timer_finished.flac" + preferences_file: str = "preferences.json" + debug: bool = False + + +@dataclass +class Config: + """Main configuration object.""" + app: AppConfig + audio: AudioConfig = field(default_factory=AudioConfig) + wake_word: WakeWordConfig = field(default_factory=WakeWordConfig) + 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 +# ----------------------------------------------------------------------------- + +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: + raw_data = json.load(f) + except FileNotFoundError: + _LOGGER.critical(f"Configuration file not found at: {config_path}") + raise + except json.JSONDecodeError as e: + _LOGGER.critical(f"Error parsing configuration file: {e}") + raise + + # --- 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'." ) + + 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 + + # --- Step 4: Return the main Config object --- + return Config( + app=app_config, + audio=audio_config, + wake_word=wake_word_config, + esphome=esphome_config, + led=led_config, + mqtt=mqtt_config, + button=button_config, + ) From 1a1c15f8363921ba5af4336558428f4e49353243 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:21:54 -0800 Subject: [PATCH 43/59] Update __main__.py version mismatch fix From cc2a7a62062d2a660dce81e494fa70fd6ebc55ee Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 12:22:34 -0800 Subject: [PATCH 44/59] Update audio_volume.py version mismatch fix From 78919973926d5f48b46d6df3298219e069ab5211 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:35:52 -0800 Subject: [PATCH 45/59] Update audio_volume.py Add volume max percentage and config for volume sync. --- linux_voice_assistant/audio_volume.py | 65 ++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/linux_voice_assistant/audio_volume.py b/linux_voice_assistant/audio_volume.py index a5f3a7e..37ff1fa 100644 --- a/linux_voice_assistant/audio_volume.py +++ b/linux_voice_assistant/audio_volume.py @@ -88,41 +88,77 @@ def _run_cmd(cmd: list[str], timeout_s: float = 2.0) -> tuple[bool, str]: def set_output_volume( volume_0_1: float, output_device: Optional[str] = None, + max_volume_percent: int = 100, logger: logging.Logger = _LOGGER, ) -> bool: """Set OS output volume to match LVA volume (0.0–1.0). -Returns True if any backend succeeded. -""" + `max_volume_percent` lets you map LVA's 100% to something above 100% on the + OS sink (e.g. 150 for 1.5x), which can be useful on devices that need + boosted output. + + Returns True if any backend succeeded. + """ vol = _clamp01(volume_0_1) + try: + max_pct = int(max_volume_percent) + except Exception: + max_pct = 100 + # Avoid weird values; allow >100 for boosted sinks. + max_pct = max(0, min(200, max_pct)) + sink_scalar = vol * (max_pct / 100.0) # --- 1) PipeWire: wpctl ------------------------------------------------- if shutil.which("wpctl"): # wpctl accepts @DEFAULT_AUDIO_SINK@ and a linear factor (e.g. 0.40) - ok, out = _run_cmd(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{vol:.3f}"]) + ok, out = _run_cmd( + [ + "wpctl", + "set-volume", + "@DEFAULT_AUDIO_SINK@", + f"{sink_scalar:.3f}", + ] + ) if ok: - logger.debug("Set PipeWire sink volume via wpctl: %.3f", vol) + logger.debug( + "Set PipeWire sink volume via wpctl: scalar=%.3f (vol=%.3f max_pct=%d)", + sink_scalar, + vol, + max_pct, + ) return True logger.debug("wpctl set-volume failed: %s", out) # --- 2) PulseAudio: pactl ---------------------------------------------- if shutil.which("pactl"): sink = _pactl_sink_from_output_device(output_device) - pct = int(round(vol * 100.0)) + pct = int(round(sink_scalar * 100.0)) ok, out = _run_cmd(["pactl", "set-sink-volume", sink, f"{pct}%"]) if ok: - logger.debug("Set Pulse sink volume via pactl: sink=%s pct=%d", sink, pct) + logger.debug( + "Set Pulse sink volume via pactl: sink=%s pct=%d (vol=%.3f max_pct=%d)", + sink, + pct, + vol, + max_pct, + ) return True logger.debug("pactl set-sink-volume failed: %s", out) # --- 3) ALSA: amixer ---------------------------------------------------- if shutil.which("amixer"): - pct = int(round(vol * 100.0)) + pct = int(round(sink_scalar * 100.0)) # Common mixer controls across SBC images. for control in ("Master", "PCM", "Speaker", "Headphone"): ok, out = _run_cmd(["amixer", "-q", "sset", control, f"{pct}%"]) if ok: - logger.debug("Set ALSA volume via amixer: control=%s pct=%d", control, pct) + logger.debug( + "Set ALSA volume via amixer: control=%s pct=%d (vol=%.3f max_pct=%d)", + control, + pct, + vol, + max_pct, + ) return True logger.debug("amixer sset %s failed: %s", control, out) @@ -132,16 +168,23 @@ Returns True if any backend succeeded. async def ensure_output_volume( volume: float, output_device: Optional[str] = None, + max_volume_percent: int = 100, attempts: int = 10, delay_seconds: float = 0.5, logger: logging.Logger = _LOGGER, ) -> bool: """Retry output volume sync during startup. -Useful at boot when PipeWire/Pulse/ALSA might not be fully ready yet. -""" + Useful at boot when PipeWire/Pulse/ALSA might not be fully ready yet. + """ for i in range(1, max(1, attempts) + 1): - ok = await asyncio.to_thread(set_output_volume, volume, output_device, logger) + ok = await asyncio.to_thread( + set_output_volume, + volume, + output_device, + max_volume_percent, + logger, + ) if ok: return True if i < attempts: From 6866358dfee26370f19aadbcdad87162b381f32b Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:36:28 -0800 Subject: [PATCH 46/59] Update config.py Add volume max percentage and config for volume sync. --- linux_voice_assistant/config.py | 105 +++++++++++++++++++------------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/linux_voice_assistant/config.py b/linux_voice_assistant/config.py index cde815c..f3a3612 100644 --- a/linux_voice_assistant/config.py +++ b/linux_voice_assistant/config.py @@ -1,17 +1,29 @@ """Configuration models for the application.""" +from __future__ import annotations + import json +import logging from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional +from typing import List, Optional -import logging _LOGGER = logging.getLogger(__name__) # ----------------------------------------------------------------------------- # Configuration Dataclasses # ----------------------------------------------------------------------------- +@dataclass +class AppConfig: + """General application settings.""" + name: str + wakeup_sound: str = "sounds/wake_word_triggered.flac" + timer_finished_sound: str = "sounds/timer_finished.flac" + preferences_file: str = "preferences.json" + debug: bool = False + + @dataclass class AudioConfig: """Settings for audio input and output.""" @@ -19,6 +31,21 @@ class AudioConfig: input_block_size: int = 1024 output_device: Optional[str] = None + # If True, LVA will attempt to set the OS sink volume to match the persisted + # volume in preferences.json on startup (PipeWire/PulseAudio/ALSA best-effort). + # Default is False to avoid surprising changes on systems where users manage + # volume externally. + volume_sync: bool = False + + # Maximum sink volume to map to LVA's 100% volume. + # + # Example: if max_volume_percent=150 then when LVA's media player reports + # 100% (preferences.volume_level == 1.0), the underlying sink will be set + # to 150% (or 1.5 for backends that use scalar volume). + # + # This is useful for devices that need >100% gain on PipeWire/Pulse/ALSA. + max_volume_percent: int = 100 + @dataclass class WakeWordConfig: @@ -39,20 +66,22 @@ class ESPHomeConfig: @dataclass class LedConfig: - """Settings for the LED strip. - - Fields: - enabled -> master on/off for hardware LEDs. If False, LedController - still subscribes to events and publishes MQTT state, but - never touches hardware. - led_type -> "dotstar", "neopixel", or "xvf3800" - interface -> "spi", "gpio", "usb", or "i2c" (future) - """ + """Settings for LEDs.""" enabled: bool = True + + # Supported values include: + # - "dotstar" / "neopixel" for Pi-attached LED strips + # - "xvf3800" for the ReSpeaker XVF3800 USB LED ring backend led_type: str = "dotstar" + + # For dotstar/neopixel: "spi" or "gpio" + # For xvf3800: "usb" interface: str = "spi" + + # GPIO pin numbers used when interface="gpio" clock_pin: int = 13 data_pin: int = 12 + # Note: overridden by 'preferences.json' if it exists num_leds: int = 3 @@ -69,39 +98,26 @@ class MqttConfig: @dataclass class ButtonConfig: - """ - Settings for a hardware momentary button. + """Settings for a hardware momentary button.""" - mode: - - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT) - - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800 - - For mode="gpio": - - 'pin' and 'long_press_seconds' are used (short vs long press). - For mode="xvf3800": - - The XVF3800ButtonController uses the built-in mute button purely - as a mute toggle; 'pin' and 'long_press_seconds' are ignored. - """ + # Overall enable/disable (default: off) enabled: bool = False - mode: str = "gpio" # "gpio" or "xvf3800" + + # mode: + # - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT) + # - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800 + mode: str = "gpio" + # BCM GPIO pin number for the button input (gpio mode only). pin: int = 17 + # Press duration (in seconds) to be considered a "long press" (gpio mode). long_press_seconds: float = 1.0 + # Poll interval used by both GPIO and XVF3800 controllers. poll_interval_seconds: float = 0.01 -@dataclass -class AppConfig: - """General application settings.""" - name: str - wakeup_sound: str = "sounds/wake_word_triggered.flac" - timer_finished_sound: str = "sounds/timer_finished.flac" - preferences_file: str = "preferences.json" - debug: bool = False - - @dataclass class Config: """Main configuration object.""" @@ -120,22 +136,18 @@ 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: raw_data = json.load(f) except FileNotFoundError: - _LOGGER.critical(f"Configuration file not found at: {config_path}") + _LOGGER.critical("Configuration file not found at: %s", config_path) raise except json.JSONDecodeError as e: - _LOGGER.critical(f"Error parsing configuration file: {e}") + _LOGGER.critical("Error parsing configuration file: %s", e) raise - # --- 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", {})) @@ -145,11 +157,18 @@ def load_config_from_json(config_path: Path) -> Config: mqtt_config = MqttConfig(**raw_data.get("mqtt", {})) button_config = ButtonConfig(**raw_data.get("button", {})) - # --- Step 3: Set MQTT 'enabled' flag --- + # Back-compat: allow top-level "volume_sync" (preferred location is audio.volume_sync) + if "volume_sync" in raw_data and "volume_sync" not in raw_data.get("audio", {}): + try: + audio_config.volume_sync = bool(raw_data.get("volume_sync")) + except Exception: + # If it's something strange, just leave default. + pass + + # Set MQTT 'enabled' flag if mqtt_config.host: mqtt_config.enabled = True - # --- Step 4: Return the main Config object --- return Config( app=app_config, audio=audio_config, From 010e5447df2115331e4fc49b46596e3e92aeef89 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:37:00 -0800 Subject: [PATCH 47/59] Update __main__.py Add volume max percentage and config for volume sync. --- linux_voice_assistant/__main__.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/linux_voice_assistant/__main__.py b/linux_voice_assistant/__main__.py index 2d95fba..d044d19 100644 --- a/linux_voice_assistant/__main__.py +++ b/linux_voice_assistant/__main__.py @@ -169,17 +169,21 @@ async def main() -> None: # --- 5b. Sync OS sink volume to persisted volume --- # mpv's per-player volume is kept at 100% and ducking is handled within mpv. # The user-visible "volume" in HA maps to the OS output volume (PipeWire/Pulse/ALSA). - try: - loop.create_task( - ensure_output_volume( - volume=preferences.volume_level, - output_device=config.audio.output_device, - attempts=20, - delay_seconds=0.5, + if getattr(config.audio, "volume_sync", False): + try: + loop.create_task( + ensure_output_volume( + volume=preferences.volume_level, + output_device=config.audio.output_device, + max_volume_percent=getattr(config.audio, "max_volume_percent", 100), + attempts=20, + delay_seconds=0.5, + ) ) - ) - except Exception: - _LOGGER.exception("Failed to schedule output volume sync") + except Exception: + _LOGGER.exception("Failed to schedule output volume sync") + else: + _LOGGER.debug("Output volume sync disabled (audio.volume_sync=false)") # --- 6. Create Server State --- state = _create_server_state( From 05817ceb58d024aa076cbaf27ac528194609f925 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:46:32 -0800 Subject: [PATCH 48/59] Update config.json.example Added volume_sync and max_volume_percent options. --- linux_voice_assistant/config.json.example | 26 +++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/linux_voice_assistant/config.json.example b/linux_voice_assistant/config.json.example index 989952f..0e447f0 100644 --- a/linux_voice_assistant/config.json.example +++ b/linux_voice_assistant/config.json.example @@ -14,7 +14,7 @@ }, // --------------------------------------------------------------------------- - // Optional: Audio I/O + // Optional: Audio I/O + Output Volume Sync // --------------------------------------------------------------------------- "audio": { // Optional: @@ -29,7 +29,29 @@ // Optional: // - null/omit => mpv chooses default output // - string => mpv audio-device (e.g. "pulse/alsa_output....") - "output_device": null + "output_device": null, + + // Optional (NEW): Sync the host OS output device volume at startup to match + // the volume stored in preferences.json (LVA MediaPlayer volume). + // + // When enabled, LVA will attempt (in order): wpctl -> pactl -> amixer. + // This helps when PipeWire/Pulse/ALSA boots with a low default volume. + // + // Default: false + "volume_sync": false, + + // Optional (NEW): Maximum output volume percent for the underlying sink. + // + // Many PipeWire/PulseAudio sinks support >100% (e.g. 150%) "amplified" volume. + // This sets the ceiling used when mapping LVA volume (0.0–1.0) to the sink. + // + // Example: + // - max_volume_percent: 150 + // - LVA volume 1.0 => sink set to 150% + // - LVA volume 0.5 => sink set to 75% + // + // Default: 100 + "max_volume_percent": 100 }, // --------------------------------------------------------------------------- From 90fa3a9df71580a7a0f852669a3127488c0696af Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:47:37 -0800 Subject: [PATCH 49/59] Update pyproject.toml updated the aioesphomeapi dependency to 43.9.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b0bfbf0..04afe01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.9.0" dependencies = [ - "aioesphomeapi==42.7.0", + "aioesphomeapi==43.9.1", "soundcard<1", "numpy>=2,<3", "pymicro-wakeword>=2,<3", From fed867936ab9ce9d0fd4b386695833ab7682a34c Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:50:02 -0800 Subject: [PATCH 50/59] Update xvf3800_button_controller.py Fix physical mute button not illuminating the led ring with the Mute State. --- .../xvf3800_button_controller.py | 110 ++++++++---------- 1 file changed, 51 insertions(+), 59 deletions(-) diff --git a/linux_voice_assistant/xvf3800_button_controller.py b/linux_voice_assistant/xvf3800_button_controller.py index dcb9187..4e75c3e 100644 --- a/linux_voice_assistant/xvf3800_button_controller.py +++ b/linux_voice_assistant/xvf3800_button_controller.py @@ -82,6 +82,8 @@ class XVF3800USBClient: # Index of the mic mute/red LED pin in the GPO_READ_VALUES response # (X0D30, second element). GPO_MUTE_INDEX = 1 + # Index of the WS2812 LED power enable pin in the response (X0D33). + GPO_WS2812_POWER_INDEX = 3 def __init__(self) -> None: dev = usb.core.find(idVendor=self.VENDOR_ID, idProduct=self.PRODUCT_ID) @@ -159,6 +161,26 @@ class XVF3800USBClient: ) return values + def set_gpo_pin(self, pin: int, value: bool) -> bool: + """Set a single GPO pin high/low. + + Args: + pin: GPO pin number (e.g., 30 for X0D30, 33 for X0D33) + value: True -> high, False -> low + + Returns: + True on success, False on error. + """ + payload = bytes([pin, 1 if value else 0]) + try: + self._ctrl_write(self.GPO_RESID, self.GPO_WRITE_CMDID, payload) + return True + except usb.core.USBError as err: + _LOGGER.error("USBError writing XVF3800 GPO pin %s value: %s", pin, err) + except Exception: + _LOGGER.exception("Unexpected error writing XVF3800 GPO pin %s value", pin) + return False + def get_mute_gpo(self) -> Optional[bool]: """Return the current mic-mute output state from X0D30. @@ -183,31 +205,14 @@ class XVF3800USBClient: return None def set_mute_gpo(self, muted: bool) -> bool: - """Set the mic-mute output (X0D30) high/low. - - Args: - muted: True to mute mics + turn red LED on, False to unmute. - - Returns: - True on success, False on error. - """ - # Payload: [pin_index, value] as uint8 each - payload = bytes([30, 1 if muted else 0]) - try: - self._ctrl_write(self.GPO_RESID, self.GPO_WRITE_CMDID, payload) - return True - except usb.core.USBError as err: - _LOGGER.error("USBError writing XVF3800 GPO mute value: %s", err) - except Exception: - _LOGGER.exception("Unexpected error writing XVF3800 GPO mute value") - return False + """Set the mic-mute output (X0D30) high/low.""" + return self.set_gpo_pin(30, muted) def close(self) -> None: """Dispose of USB resources.""" try: usb.util.dispose_resources(self._dev) except Exception: - # Not fatal; just log at debug level. _LOGGER.debug("Error disposing XVF3800 USB resources", exc_info=True) @@ -224,23 +229,7 @@ class XVF3800ButtonRuntimeConfig: class XVF3800ButtonController(EventHandler): - """Monitor & synchronize the XVF3800's mute state with LVA. - - Responsibilities: - - - Poll X0D30 (GPO_READ_VALUES) on a background thread. - - When the hardware mute state changes (e.g. user presses the - XVF3800's mute button), publish "set_mic_mute" with the new - state so MicMuteHandler can update ServerState, MQTT, LEDs, etc. - - Listen for "mic_muted" and "mic_unmuted" events and mirror those - changes back to the XVF3800 by setting X0D30 via GPO_WRITE_VALUE. - - IMPORTANT: - - This controller does *not* implement short/long press semantics. - The XVF3800's onboard button is treated as a dedicated mute - toggle; wake/stop behavior still comes from wake words or a - separate GPIO button. - """ + """Monitor & synchronize the XVF3800's mute state with LVA.""" def __init__( self, @@ -253,7 +242,6 @@ class XVF3800ButtonController(EventHandler): self.loop = loop self.state = state - # Poll interval comes from the button config if present. poll_interval = 0.05 if hasattr(button_config, "poll_interval_seconds"): try: @@ -272,18 +260,13 @@ class XVF3800ButtonController(EventHandler): self._thread: Optional[threading.Thread] = None self._shutdown_flag = threading.Event() - # Last known hardware mute state (bool) or None if unknown. self._last_hw_muted: Optional[bool] = None - # Target mute state from LVA (set via mic_muted/mic_unmuted events). self._target_mute_state_lock = threading.Lock() self._target_mute_state: Optional[bool] = None - # USB client is created lazily in the polling thread so that any - # USB errors don't break __init__. self._usb_client: Optional[XVF3800USBClient] = None - # Start polling thread immediately. self._thread = threading.Thread( target=self._poll_loop, name="XVF3800ButtonControllerThread", @@ -295,7 +278,6 @@ class XVF3800ButtonController(EventHandler): self._cfg.poll_interval_seconds, ) - # Subscribe to events *after* initialization is complete. self._subscribe_all_methods() # ------------------------------------------------------------------ @@ -304,12 +286,10 @@ class XVF3800ButtonController(EventHandler): @subscribe def mic_muted(self, data: dict) -> None: - """Mirror LVA mute -> XVF3800 hardware.""" self._set_target_mute_state(True) @subscribe def mic_unmuted(self, data: dict) -> None: - """Mirror LVA mute -> XVF3800 hardware.""" self._set_target_mute_state(False) # ------------------------------------------------------------------ @@ -317,7 +297,6 @@ class XVF3800ButtonController(EventHandler): # ------------------------------------------------------------------ def stop(self) -> None: - """Signal the polling thread to stop and wait for it.""" self._shutdown_flag.set() if self._thread is not None: self._thread.join(timeout=2.0) @@ -330,19 +309,16 @@ class XVF3800ButtonController(EventHandler): # ------------------------------------------------------------------ def _set_target_mute_state(self, muted: bool) -> None: - """Request that the polling thread set the XVF3800 mute state.""" with self._target_mute_state_lock: self._target_mute_state = muted def _take_target_mute_state(self) -> Optional[bool]: - """Atomically fetch and clear the pending target mute state.""" with self._target_mute_state_lock: value = self._target_mute_state self._target_mute_state = None return value def _ensure_usb_client(self) -> Optional[XVF3800USBClient]: - """Lazy-initialize the USB client on the polling thread.""" if self._usb_client is not None: return self._usb_client @@ -362,7 +338,6 @@ class XVF3800ButtonController(EventHandler): # ------------------------------------------------------------------ def _poll_loop(self) -> None: - """Background loop to synchronize mute state with XVF3800.""" _LOGGER.debug("XVF3800ButtonController polling thread started") while not self._shutdown_flag.is_set() and not getattr( @@ -370,7 +345,6 @@ class XVF3800ButtonController(EventHandler): ): client = self._ensure_usb_client() if client is None: - # If we failed to init USB, wait a bit and retry time.sleep(2.0) continue @@ -385,23 +359,42 @@ class XVF3800ButtonController(EventHandler): "Set XVF3800 hardware mute state -> %s", target ) - # 2) Read current hardware GPO mute state - hw_muted = client.get_mute_gpo() + # 2) Read current hardware GPO values (mute + WS2812 power) + try: + values = client.read_gpo_values() + hw_muted = None + if len(values) > client.GPO_MUTE_INDEX: + hw_muted = bool(values[client.GPO_MUTE_INDEX]) + + # If the firmware/button toggles WS2812 power off, re-enable it so + # the ring can still display LVA state (mute, idle effects, etc.) + if len(values) > client.GPO_WS2812_POWER_INDEX: + ws2812_power = bool(values[client.GPO_WS2812_POWER_INDEX]) + if not ws2812_power: + if client.set_gpo_pin(33, True): + _LOGGER.debug("Re-enabled XVF3800 WS2812 LED power (X0D33)") + else: + ws2812_power = None + + except usb.core.USBError as err: + _LOGGER.error("USBError reading XVF3800 GPO values: %s", err) + hw_muted = None + except Exception: + _LOGGER.exception("Unexpected error reading XVF3800 GPO values") + hw_muted = None + if hw_muted is not None: if self._last_hw_muted is None: - # First observation; treat hardware as source of truth self._last_hw_muted = hw_muted _LOGGER.info( "Initial XVF3800 hardware mute state: %s", hw_muted ) - # Align LVA state with hardware on first read self.loop.call_soon_threadsafe( self.state.event_bus.publish, "set_mic_mute", - {"state": hw_muted}, + {"state": hw_muted, "source": "xvf3800_hw"}, ) elif hw_muted != self._last_hw_muted: - # Hardware mute changed (button pressed) _LOGGER.info( "Detected XVF3800 mute state change from %s to %s; " "publishing set_mic_mute event", @@ -412,10 +405,9 @@ class XVF3800ButtonController(EventHandler): self.loop.call_soon_threadsafe( self.state.event_bus.publish, "set_mic_mute", - {"state": hw_muted}, + {"state": hw_muted, "source": "xvf3800_hw"}, ) time.sleep(self._cfg.poll_interval_seconds) _LOGGER.debug("XVF3800ButtonController polling thread exiting") - From 1da9ac1facf21ef2ab2c2d407805ca3e42ddbffc Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:23:33 -0800 Subject: [PATCH 51/59] Update audio_engine.py Summary Add thread-safe locking for wake word reload logic Problem Wake word reload logic accessed self.state.wake_words_changed flag without synchronization, creating a potential race condition when wake words are dynamically switched via Home Assistant. Changes Added self._wake_words_lock = threading.Lock() in __init__ Wrapped wake word reload block with with self._wake_words_lock: context manager Ensures atomic read-modify-write of wake_words_changed flag Impact Risk: Very low - purely defensive improvement Benefit: Eliminates race condition in wake word switching Performance: Negligible overhead (lock only held during reload check) --- linux_voice_assistant/audio_engine.py | 55 +++++++++++++++------------ 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/linux_voice_assistant/audio_engine.py b/linux_voice_assistant/audio_engine.py index 8b23095..7d74ff5 100644 --- a/linux_voice_assistant/audio_engine.py +++ b/linux_voice_assistant/audio_engine.py @@ -27,6 +27,9 @@ class AudioEngine: self.mic = mic self.block_size = block_size self._thread: Optional[threading.Thread] = None + + # CRITICAL FIX: Add lock for thread-safe wake word reload + self._wake_words_lock = threading.Lock() def start(self): """Starts the audio processing thread.""" @@ -113,33 +116,35 @@ class AudioEngine: time.sleep(0.01) continue - # Update active wake words if changed - if (not wake_words) or (self.state.wake_words_changed): - self.state.wake_words_changed = False - wake_words = [ - ww - for ww in self.state.wake_words.values() - if ww.id in self.state.active_wake_words - ] - has_oww = any( - isinstance(ww, OpenWakeWord) for ww in wake_words - ) - - if has_oww and (oww_features is None): - _LOGGER.debug( - "Initializing OpenWakeWord features..." + # CRITICAL FIX: Protect wake word reload with lock + with self._wake_words_lock: + # Update active wake words if changed + if (not wake_words) or (self.state.wake_words_changed): + self.state.wake_words_changed = False + wake_words = [ + ww + for ww in self.state.wake_words.values() + if ww.id in self.state.active_wake_words + ] + has_oww = any( + isinstance(ww, OpenWakeWord) for ww in wake_words ) - oww_features = OpenWakeWordFeatures.from_builtin() - _LOGGER.debug( - "Rebuilt wake_words list: %s (has_oww=%s, active_ids=%s)", - [ - getattr(ww, "wake_word", getattr(ww, "id", "?")) - for ww in wake_words - ], - has_oww, - self.state.active_wake_words, - ) + if has_oww and (oww_features is None): + _LOGGER.debug( + "Initializing OpenWakeWord features..." + ) + oww_features = OpenWakeWordFeatures.from_builtin() + + _LOGGER.debug( + "Rebuilt wake_words list: %s (has_oww=%s, active_ids=%s)", + [ + getattr(ww, "wake_word", getattr(ww, "id", "?")) + for ww in wake_words + ], + has_oww, + self.state.active_wake_words, + ) try: # Feature extraction From d10cf87eef70e98f8cc14c1af0b853a02de3fb03 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:24:38 -0800 Subject: [PATCH 52/59] Update xvf3800_led_backend.py Problem XVF3800 LED ring worked inconsistently (~50% success rate at startup) due to: 1. Timing race: LED commands sent before WS2812 power (X0D33) enabled 2. Missing context manager: USB resources could leak on exceptions Changes Context Manager Support (Critical Fix B) Added __enter__ and __exit__ methods to _ReSpeaker class Enhanced close() with proper null checks and error handling Enables safe with statement usage LED Power Management (NEW Critical Fix) Proactive initialization: Enable X0D33 immediately in __init__ before any LED operations Defensive runtime checks: Added _ensure_led_power() helper method Power verification: Check X0D33 state before set_effect() and set_ring_colors() Added 50ms settle time after enabling power Documentation Added GPO_WRITE_VALUE and GPO_READ_VALUES to PARAMETERS dict Added GPO_WS2812_POWER_INDEX constant for clarity Enhanced inline comments explaining power management --- linux_voice_assistant/xvf3800_led_backend.py | 93 +++++++++++++++++--- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/linux_voice_assistant/xvf3800_led_backend.py b/linux_voice_assistant/xvf3800_led_backend.py index 31bfd81..96083b7 100644 --- a/linux_voice_assistant/xvf3800_led_backend.py +++ b/linux_voice_assistant/xvf3800_led_backend.py @@ -54,6 +54,10 @@ PARAMETERS = { # Newer firmware: per-LED ring control (12 WS2812 LEDs) "LED_RING_COLOR": (20, 19, 12, "rw", "uint32"), + + # GPO control for button monitoring and LED power + "GPO_READ_VALUES": (20, 0, 5, "ro", "uint8"), # [X0D11, X0D30, X0D31, X0D33, X0D39] + "GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8"), # [pin_index, value] } @@ -67,13 +71,25 @@ class _ReSpeaker: def __init__(self, dev: "usb.core.Device") -> None: # type: ignore[name-defined] self.dev = dev + # CRITICAL FIX: Add context manager support + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + return False + def close(self) -> None: """Release any underlying libusb resources (best-effort).""" - try: - usb.util.dispose_resources(self.dev) - except Exception: - pass - + if hasattr(self, 'dev') and self.dev is not None: + try: + usb.util.dispose_resources(self.dev) + except Exception as e: + _LOGGER.debug("Error disposing USB resources: %s", e) + finally: + self.dev = None # ------------------------------------------------------------------ # Encoding / decoding helpers @@ -181,9 +197,6 @@ class _ReSpeaker: raise RuntimeError(f"XVF3800 control read failed (status={status}, name={name})") - def close(self) -> None: - usb.util.dispose_resources(self.dev) - def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Optional[_ReSpeaker]: dev = usb.core.find(idVendor=vid, idProduct=pid) @@ -192,7 +205,6 @@ def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Option return _ReSpeaker(dev) - class XVF3800USBDevice: """Small helper for issuing non-LED XVF3800 control commands via PyUSB. @@ -201,12 +213,17 @@ class XVF3800USBDevice: def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID): self._rsp = _find_device(vid=vid, pid=pid) + if self._rsp is None: + raise RuntimeError( + f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})" + ) def close(self) -> None: - try: - self._rsp.close() - except Exception: - pass + if self._rsp is not None: + try: + self._rsp.close() + except Exception: + pass # --- Device control ----------------------------------------------------- @@ -255,10 +272,14 @@ class XVF3800USBDevice: if settle_s > 0: time.sleep(settle_s) + class XVF3800LedBackend: """High-level LED backend for the XVF3800.""" ring_led_count: int = 12 + + # GPO indices (from XVF3800 documentation) + GPO_WS2812_POWER_INDEX = 3 # X0D33 in GPO_READ_VALUES response def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> None: wrapper = _find_device(vid, pid) @@ -269,6 +290,18 @@ class XVF3800LedBackend: self._dev = wrapper self.supports_per_led: bool = False + + # CRITICAL FIX: Ensure WS2812 LED power is enabled BEFORE any LED operations + # This prevents intermittent LED failures caused by X0D33 being low at startup + try: + _LOGGER.debug("Ensuring XVF3800 WS2812 LED power (X0D33) is enabled") + self._dev.write("GPO_WRITE_VALUE", [33, 1]) # X0D33 = high + time.sleep(0.05) # Give firmware time to settle + _LOGGER.info("XVF3800 WS2812 LED power enabled") + except Exception as e: + _LOGGER.warning( + "Could not enable WS2812 LED power, LEDs may not work reliably: %s", e + ) # Best-effort feature detection: if we can read LED_RING_COLOR, we assume # per-LED control is supported by the current firmware. @@ -296,12 +329,43 @@ class XVF3800LedBackend: self.supports_per_led, ) + # --------------------------------------------------------------------- + # Helper: Ensure LED power before critical operations + # --------------------------------------------------------------------- + + def _ensure_led_power(self) -> bool: + """Ensure WS2812 LED power is enabled before operations. + + This provides belt-and-suspenders protection against the LED power + being disabled by firmware or button interactions. + + Returns: + bool: True if power is confirmed on, False if check failed + """ + try: + # Read GPO values + values = self._dev.read("GPO_READ_VALUES") + if len(values) > self.GPO_WS2812_POWER_INDEX: + ws2812_power = bool(values[self.GPO_WS2812_POWER_INDEX]) + if not ws2812_power: + _LOGGER.warning("WS2812 LED power was off, re-enabling") + self._dev.write("GPO_WRITE_VALUE", [33, 1]) + time.sleep(0.01) # Brief settle time + return True + return True + except Exception as e: + _LOGGER.debug("Could not verify WS2812 LED power state: %s", e) + return False + return False + # --------------------------------------------------------------------- # Legacy/global controls # --------------------------------------------------------------------- def set_effect(self, effect_id: int) -> None: """Set LED effect mode (0=off, 1=breath, 2=rainbow, 3=single color, 4=doa).""" + # Ensure power before effect change + self._ensure_led_power() self._dev.write("LED_EFFECT", [int(effect_id) & 0xFF]) def set_brightness(self, brightness_0_255: int) -> None: @@ -333,6 +397,9 @@ class XVF3800LedBackend: raise ValueError( f"LED_RING_COLOR expects {self.ring_led_count} values, got {len(color_values)}" ) + + # Ensure power before writing ring colors + self._ensure_led_power() self._dev.write("LED_RING_COLOR", [int(v) & 0xFFFFFFFF for v in color_values]) def set_ring_rgb(self, colors: Sequence[Tuple[int, int, int]]) -> None: From 67abca1111057c400ea6d125a9e1f36af1c2564b Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:25:29 -0800 Subject: [PATCH 53/59] Update xvf3800_button_controller.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Add USB context manager support and optimize polling interval Problem Missing context manager: USB resources could leak on exceptions Aggressive polling: 100 Hz (10ms) unnecessarily high for human button presses Changes Context Manager Support (Critical Fix B) Added __enter__ and __exit__ methods to XVF3800USBClient class Enhanced close() with proper null checks and error handling Enables safe with statement usage Polling Optimization (Critical Fix C) Changed default poll_interval_seconds from 0.01 to 0.05 (100 Hz → 20 Hz) Updated XVF3800ButtonRuntimeConfig dataclass default Updated fallback default in __init__ constructor --- .../xvf3800_button_controller.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/linux_voice_assistant/xvf3800_button_controller.py b/linux_voice_assistant/xvf3800_button_controller.py index 4e75c3e..0afefa3 100644 --- a/linux_voice_assistant/xvf3800_button_controller.py +++ b/linux_voice_assistant/xvf3800_button_controller.py @@ -100,6 +100,26 @@ class XVF3800USBClient: getattr(dev, "address", "?"), ) + # CRITICAL FIX: Add context manager support + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + return False + + def close(self) -> None: + """Dispose of USB resources.""" + if hasattr(self, '_dev') and self._dev is not None: + try: + usb.util.dispose_resources(self._dev) + except Exception as e: + _LOGGER.debug("Error disposing XVF3800 USB resources: %s", e) + finally: + self._dev = None + # Internal helpers ----------------------------------------------------- def _ctrl_read(self, resid: int, cmdid: int, length: int) -> List[int]: @@ -208,13 +228,6 @@ class XVF3800USBClient: """Set the mic-mute output (X0D30) high/low.""" return self.set_gpo_pin(30, muted) - def close(self) -> None: - """Dispose of USB resources.""" - try: - usb.util.dispose_resources(self._dev) - except Exception: - _LOGGER.debug("Error disposing XVF3800 USB resources", exc_info=True) - # --------------------------------------------------------------------------- # High-level controller: bridge between XVF3800 and LVA mute state @@ -225,7 +238,9 @@ class XVF3800USBClient: class XVF3800ButtonRuntimeConfig: """Runtime config for the XVF3800 button/mute controller.""" - poll_interval_seconds: float = 0.05 # How often to poll GPO state + # CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz) + # 80% reduction in CPU usage for button monitoring + poll_interval_seconds: float = 0.05 # 20 Hz polling class XVF3800ButtonController(EventHandler): @@ -242,6 +257,7 @@ class XVF3800ButtonController(EventHandler): self.loop = loop self.state = state + # CRITICAL FIX: Default to 0.05s instead of 0.01s poll_interval = 0.05 if hasattr(button_config, "poll_interval_seconds"): try: From 4b6b6dd3805a1673a533a5dc78993dcb82086805 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:26:19 -0800 Subject: [PATCH 54/59] Update button_controller.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Optimize GPIO button polling interval Problem Aggressive polling at 100 Hz (10ms interval) unnecessarily high for human button presses, causing elevated CPU usage with no benefit to responsiveness. Changes Changed default poll_interval_seconds from 0.01 to 0.05 (100 Hz → 20 Hz) Updated ButtonRuntimeConfig dataclass default Updated fallback default in constructor --- linux_voice_assistant/button_controller.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/linux_voice_assistant/button_controller.py b/linux_voice_assistant/button_controller.py index 5751faa..ce95cf9 100644 --- a/linux_voice_assistant/button_controller.py +++ b/linux_voice_assistant/button_controller.py @@ -40,7 +40,9 @@ class ButtonRuntimeConfig: enabled: bool pin: int long_press_seconds: float - poll_interval_seconds: float = 0.01 # 100 Hz polling + # CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz) + # 80% reduction in CPU usage for button monitoring + poll_interval_seconds: float = 0.05 # 20 Hz polling class ButtonController: @@ -73,6 +75,9 @@ class ButtonController: self.event_bus = event_bus self.state = state + # CRITICAL FIX: Default poll_interval_seconds to 0.05 instead of 0.01 + default_poll_interval = 0.05 + self._cfg = ButtonRuntimeConfig( enabled=getattr(config, "enabled", False), pin=getattr(config, "pin", 17), @@ -80,9 +85,7 @@ class ButtonController: 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 + getattr(config, "poll_interval_seconds", default_poll_interval) ), ) From c527f60681ace63bfc55743fcddc4b21c08d9ec0 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:38:45 -0800 Subject: [PATCH 55/59] Create linux-voice-assistant_xvf3800.service Added wants and after for pipewire. --- service/linux-voice-assistant_xvf3800.service | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 service/linux-voice-assistant_xvf3800.service diff --git a/service/linux-voice-assistant_xvf3800.service b/service/linux-voice-assistant_xvf3800.service new file mode 100644 index 0000000..9d3b7a3 --- /dev/null +++ b/service/linux-voice-assistant_xvf3800.service @@ -0,0 +1,17 @@ +[Unit] +Description=Linux Voice Assistant +Wants=pipewire.service wireplumber.service pipewire-pulse.service +After=pipewire.service wireplumber.service pipewire-pulse.service + +[Service] +Type=simple +WorkingDirectory=/home/pi/linux-voice-assistant +Environment=PYTHONUNBUFFERED=1 + +ExecStart=/home/pi/linux-voice-assistant/script/run + +Restart=always +RestartSec=2 + +[Install] +WantedBy=default.target From 5b1ef206567a023a1aad3809e640da54cfa838b6 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:46:26 -0800 Subject: [PATCH 56/59] Update linux-voice-assistant-xvf3800.md Updated to include all of the information needed to install and configure the XVF3800. --- docs/linux-voice-assistant-xvf3800.md | 380 +++++++------------------- 1 file changed, 99 insertions(+), 281 deletions(-) diff --git a/docs/linux-voice-assistant-xvf3800.md b/docs/linux-voice-assistant-xvf3800.md index 38d4154..3604ddf 100644 --- a/docs/linux-voice-assistant-xvf3800.md +++ b/docs/linux-voice-assistant-xvf3800.md @@ -1,22 +1,14 @@ # Linux Voice Assistant with ReSpeaker XVF3800 4‑Mic Array — Installation & Configuration Guide -> Drafted with ChatGPT based on existing `linux-voice-assistant-2mic-install.md` and -> community test results from the XVF3800 integration Discussion. +> Drafted with ChatGPT based on existing `linux-voice-assistant-2mic-install.md` and community test results from the XVF3800 integration Discussion. -This guide shows how to use the **Seeed ReSpeaker XVF3800 USB 4‑Mic Array** with the -**linux-voice-assistant** (LVA) project on Raspberry Pi or other Linux hosts. +> Special thanks to @JenniferHatches and @cknic for making the recommendation and doing so much work already for support of the reSpeaker XVF3800. (https://github.com/cknic/linux-voice-assistant) The addition of XVF3800 support would never have happened without their troubleshooting of the code and fantastic suggestions when working through bugs. -Unlike the ReSpeaker 2‑Mic HAT, the XVF3800 is a **USB Audio Class 2.0** device: -it acts as a “smart microphone + speaker” front end with built‑in **AEC, beamforming, -noise suppression, AGC, and echo‑canceling speakerphone** processing. +This guide shows how to use the **Seeed ReSpeaker XVF3800 USB 4‑Mic Array** with the **Linux Voice Assistant** (LVA) project on Raspberry Pi or other Linux hosts. -LVA treats the XVF3800 as a **standard USB mic/speaker**: -- Audio in: 16 kHz, 16‑bit, 2‑channel PCM (downmixed to mono by the audio stack) -- Audio out: 16 kHz, 16‑bit, 2‑channel PCM via the XVF3800 headphone jack / speaker +Unlike the ReSpeaker 2‑Mic HAT, the XVF3800 is a **USB Audio Class 2.0** device: it acts as a “smart microphone + speaker” front end with built‑in **AEC, beamforming, noise suppression, AGC, and echo‑canceling speakerphone** processing. -No custom kernel drivers are required. Plug it in, select it as your audio device in -LVA’s `config.json`, and it will “just work” as a high‑quality mic for wake word and -ESPHome streaming. +No custom kernel drivers are required. Plug it in, setup the device in LVA’s `config.json`, and it will “just work” as a high‑quality mic and speaker (if connected). --- @@ -28,27 +20,65 @@ ESPHome streaming. - Other modern Linux with PipeWire or PulseAudio - Python 3.11+ recommended - **ReSpeaker XVF3800 4‑Mic Array**, connected via its **XMOS USB‑C port** -- Network access to your Home Assistant instance -- The `linux-voice-assistant` repository checked out (optionally on the `XVF3800` branch) +- SSH access to your LVA instance +- Network access to the Home Assistant for registration + +## Recommended Configuration + +- To use the advanced LED effects, upgrade to the latest XVF3800 firmware (respeaker_xvf3800_usb_dfu_firmware_v2.0.7.bin as of this writing). +-- https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY/blob/master/xmos_firmwares/dfu_guide.md +- Tested and works on a Raspberry Pi Zero 2W, but I don't recommend attempting to flash the firmware with this device. +- Pipewire (or PluseAudio) installed and tested. I haven't tested the XVF3800 with ALSA. --- -## 1. Get the code (XVF3800 branch) +## 1. Install system packages and Pipewire (Recommended) -If you don’t already have the project cloned, you can pull the dedicated XVF3800 branch: +```bash +sudo apt update +sudo apt upgrade +sudo apt install build-essential git \ + libmpv-dev mpv python3-dev python3-venv \ + pulseaudio-utils pipewire wireplumber \ + pipewire-audio libspa-0.2-modules libusb-1.0-0 +sudo reboot +``` + +--- + +## 2. Get the code (XVF3800 branch) + +If you don’t already have the project cloned: ```bash cd ~ -git clone --branch XVF3800 --single-branch https://github.com/imonlinux/linux-voice-assistant.git +git clone https://github.com/imonlinux/linux-voice-assistant.git cd linux-voice-assistant ``` -If you already have the repository, you can switch branches: +If you already have the repository, you can: ```bash +cp ~/linux-voice-assistant/linux_voice_assistant/config.json ~/ cd ~/linux-voice-assistant -git fetch origin -git checkout XVF3800 +git pull +cp ~/config.json ~/linux-voice-assistant/linux_voice_assistant/config.json +``` + +Implement the UDEV rule to prevent the XVF3800 from taking naps and to allow user access to the device + +```bash +cp ~/linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/ +``` + + +Add user to the plugdev group for access to the XVF3800 and reboot + +> Tip: If you use a different username than `pi`, adjust user accordingly. + +```bash +sudo usermod -aG plugdev pi +sudo reboot ``` Set up the project (creates a virtualenv, installs dependencies, etc.): @@ -58,206 +88,84 @@ cd ~/linux-voice-assistant script/setup ``` -> Tip: If you use a different username than `pi`, adjust paths accordingly. +1. Connect the XVF3800 to your Pi/host using the **XMOS USB‑C port** (the one next to the 3.5 mm audio jack). +2. If you want local audio output, connect the XVF3800’s headphone jack or JST-PH 2.0mm 2 Pin to an external speaker. --- -## 2. Connect and verify the XVF3800 +## 3. Update the config.json file for XVF3800 support and options -### 2.1 Plug in the board - -1. Connect the XVF3800 to your Pi/host using the **XMOS USB‑C port** (the one next to - the 3.5 mm audio jack). -2. If you want local audio output, connect the XVF3800’s headphone jack to an external - powered speaker. - -### 2.2 Confirm ALSA sees the device - -```bash -arecord -l -aplay -l -``` - -You should see entries similar to: - -```text -**** List of CAPTURE Hardware Devices **** -card 3: Array [reSpeaker XVF3800 4-Mic Array], device 0: USB Audio [USB Audio] - Subdevices: 0/1 - Subdevice #0: subdevice #0 - -**** List of PLAYBACK Hardware Devices **** -card 3: Array [reSpeaker XVF3800 4-Mic Array], device 0: USB Audio [USB Audio] - Subdevices: 1/1 - Subdevice #0: subdevice #0 -``` - -The exact card index (`3` above) may differ on your system. - -### 2.3 Quick `arecord` test (optional) - -Replace `3` with your card number: - -```bash -arecord -D plughw:3,0 -c 2 -r 16000 -f S16_LE -d 5 -vv /tmp/xvf3800-test.wav -aplay /tmp/xvf3800-test.wav -``` - -You should hear your voice through your default playback device, confirming that: -- The XVF3800 is streaming at **16 kHz**, **16‑bit**, **stereo**, and -- ALSA can capture from it reliably. - ---- - -## 3. PipeWire / PulseAudio device mapping - -On a PipeWire or PulseAudio system, check how the XVF3800 shows up at the graph level: - -```bash -pactl list short sources -pactl list short sinks -``` - -Typical output: - -```text -# Sources -309 alsa_input.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...-00.analog-stereo PipeWire s16le 2ch 16000Hz RUNNING - -# Sinks -308 alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...-00.analog-stereo PipeWire s16le 2ch 16000Hz SUSPENDED -``` - -Key points: - -- The XVF3800 appears as an **analog stereo** source/sink (`2ch 16000 Hz`). -- LVA will use these devices through the `soundcard` library. - ---- - -## 4. LVA’s view of the XVF3800 (input/output devices) - -From the project root, use LVA’s helper flags to inspect devices: - -```bash -cd ~/linux-voice-assistant -script/run -- --list-input-devices -script/run -- --list-output-devices -``` - -Example output: - -```text -Input devices - -[0] reSpeaker XVF3800 4-Mic Array Analog Stereo -``` - -```text -Output devices - -auto: Autoselect device -pipewire: Default (pipewire) -pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_...analog-stereo: reSpeaker XVF3800 4-Mic Array Analog Stereo -... -``` - -Take note of: - -- The **input device name** for the XVF3800 (e.g. `reSpeaker XVF3800 4-Mic Array Analog Stereo`) -- The output device you want to use (often `pipewire` or the specific XVF “analog‑stereo” sink) - ---- - -## 5. Configure LVA audio for XVF3800 - -Edit the main config: +Edit the config.json file: ```bash nano ~/linux-voice-assistant/linux_voice_assistant/config.json ``` -Add or update the `audio` section: +Add or update the following sections (Recommended settings): + +> Tip: Be sure to change the name if you have more than one LVA! ```json -"audio": { - "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", - "output_device": "pipewire", - "input_block_size": 1024 +{ + "app": { + "name": "Linux Voice Assistant" + }, + "led": { + "enabled": true, + "led_type": "xvf3800", + "interface": "usb" + }, + "button": { + "enabled": true, + "mode": "xvf3800", + "poll_interval_seconds": 0.15 + }, + "mqtt": { + "host": "192.168.0.100", + "port": 1883, + "username": "mqtt", + "password": "Ss1552157" + }, + "audio": { + "volume_sync": true, + "max_volume_percent": 150 + } } ``` -Examples: - -- If you want both input and output on the XVF3800: - - ```json - "audio": { - "input_device": "reSpeaker XVF3800 4-Mic Array Analog Stereo", - "output_device": "pipewire/alsa_output.usb-Seeed_Studio_reSpeaker_XVF3800_4-Mic_Array_101991441253700076-00.analog-stereo", - "input_block_size": 1024 - } - ``` - -- If you only want to use the XVF3800 as a **mic** (and another device for playback), set - `output_device` to your preferred sink from `--list-output-devices`. - -### 5.1 How channels are handled (important detail) - -The XVF3800 exposes itself as a **2‑channel** source (stereo, 16 kHz). Internally, the -LVA `AudioEngine` uses the `soundcard` library and **always requests 1 channel** from the -selected input device: - -```python -with self.mic.recorder( - samplerate=16000, - channels=1, - blocksize=self.block_size, -) as mic_in: - audio_chunk_array = mic_in.record(self.block_size).reshape(-1) - ... -``` - -This has two important implications: - -1. The audio stack (PipeWire/PulseAudio/ALSA) takes care of converting from the XVF3800’s - **2‑channel** stream to a **mono** stream before LVA sees it. -2. LVA’s wake‑word engines and ESPHome streaming always receive 16 kHz **mono** PCM - (`int16`), regardless of whether the underlying hardware is mono or stereo. - -In practice: - -> You do **not** need any XVF3800‑specific code changes in the audio engine. -> Selecting the XVF3800 as the `audio.input_device` is sufficient; LVA will process it -> as a mono mic just like other devices. +> Take a look at ~/linux-voice-assistant/linux_voice_assistant/config.json.example for details on these settings as well as all available options. --- -## 6. Start LVA as a user-mode service +## 4. Start LVA as a user-mode service The service setup is identical to other configurations (2‑Mic, etc.). Assuming you want LVA to run as a user‑mode systemd service: -### 6.1 Enable linger for your user (e.g. `pi`) +Enable linger for your user (e.g. `pi`) ```bash sudo loginctl enable-linger pi ``` -### 6.2 Install the systemd user service +Install the systemd user service + +> Tip: If you use a different username than `pi`, adjust user accordingly. ```bash mkdir -p ~/.config/systemd/user -cp ~/linux-voice-assistant/service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service +cp ~/linux-voice-assistant/service/linux-voice-assistant_xvf3800.service ~/.config/systemd/user/linux-voice-assistant.service ``` -### 6.3 Enable & start +Enable & start ```bash systemctl --user daemon-reload systemctl --user enable --now linux-voice-assistant.service ``` +> Note: The LVA initiate a XVF3800 reboot at start, this is needed to resolve a "mic not detecting audio" issue. As a result, the LVA will take a little longer to start up and be ready. + Check status: ```bash @@ -272,69 +180,22 @@ Look for log lines indicating: --- -## 7. Acoustic Echo Cancellation (AEC) considerations +## 5. Acoustic Echo Cancellation (AEC) The XVF3800 already implements **hardware AEC / echo‑canceling speakerphone** processing. -In many cases, you can rely **only** on the XVF3800’s built‑in AEC and **skip** the -PipeWire/PulseAudio `module-echo-cancel` setup. - -You have three practical options: - -### 7.1 Rely on XVF3800 AEC only (recommended starting point) - -- Do **not** load any user‑mode echo‑cancel module. -- Set `input_device` to the XVF3800 source (or an echo‑cancel source that wraps it, if your - system has one by default). -- Play media (music, games, etc.) through the XVF3800 or another output and test: - - Wake word recognition reliability - - STT quality in noisy playback conditions - -If it works well, this is the simplest and most CPU‑efficient setup. - -### 7.2 Combine XVF3800 with software AEC - -If you already have a PipeWire/PulseAudio echo‑cancel device configured, you can route -XVF3800 through it: - -1. Create an echo‑cancel source/sink pair (see `install_pipewire.md` or - `install_pulseaudio.md`). -2. Use `script/run --list-input-devices` / `--list-output-devices` to find names like - `Echo-Cancel Source` and `pipewire/echo-cancel-sink`. -3. Point LVA at those devices in `config.json`: - - ```json - "audio": { - "input_device": "Echo-Cancel Source", - "input_block_size": 1024, - "output_device": "pipewire/echo-cancel-sink" - } - ``` - -This stacks XVF3800’s hardware processing with additional software AEC. It may help in -particularly difficult acoustic environments, at the cost of some complexity and CPU. - -### 7.3 No AEC at all (not recommended for open‑air speakers) - -You can also run without any AEC (hardware or software) if: - -- You use **headphones** instead of speakers, or -- The device isn’t playing audio while listening (e.g. strictly microphone‑only role). - -In that case, you can treat XVF3800 as “just another USB mic” and ignore its speakerphone -capabilities. --- -## 8. Troubleshooting & debug tips +## 6. Troubleshooting & debug tips -### 8.1 Check that LVA sees the XVF3800 +### Check that LVA sees the XVF3800 -Re‑run: +Make sure the XVF3800 is available to the LVA code: ```bash cd ~/linux-voice-assistant -script/run -- --list-input-devices -script/run -- --list-output-devices +script/run --list-input-devices +script/run --list-output-devices ``` If `reSpeaker XVF3800 4-Mic Array Analog Stereo` doesn’t appear: @@ -347,7 +208,7 @@ If `reSpeaker XVF3800 4-Mic Array Analog Stereo` doesn’t appear: lsusb | grep -i 2886 ``` -### 8.2 Tail the LVA logs +### Tail the LVA logs ```bash journalctl --user -u linux-voice-assistant.service -f @@ -358,46 +219,3 @@ Look for: - “Audio engine started.” - “Opening audio input device: reSpeaker XVF3800 4-Mic Array Analog Stereo” - Wake‑word detection lines (MicroWakeWord / OpenWakeWord) - -### 8.3 Optional: XVF3800 probe script - -On the `XVF3800` branch, there is a small probe script in `tests/xvf3800_probe.py` that -can be used to inspect how the audio backend opens the device. - -For example: - -```bash -cd ~/linux-voice-assistant -python tests/xvf3800_probe.py --device "reSpeaker XVF3800 4-Mic Array Analog Stereo" -``` - -This will print: - -- The device list from the `soundcard` backend -- The samplerate and channel count used when opening the stream -- The shape and dtype of the recorded buffer - -It’s mainly a debug tool for development and Discussion threads; you don’t need it for -normal operation. - ---- - -## 9. Next steps: LEDs, button, and DOA integration - -This document covers **audio‑only** integration for the XVF3800. The board also has: - -- A 12‑LED WS2812 ring -- A hardware mute button and mute LED -- Direction‑of‑arrival (DOA) and voice activity indicators - -Future work in the LVA project may add an optional `Xvf3800Controller` that: - -- Talks to the XVF3800 over USB using a Python SDK (pyusb or HID) -- Publishes DOA, VAD, and mute button events to LVA’s internal event bus -- Synchronizes mute state and LED behavior between XVF3800, LVA, and Home Assistant - -For now, the important takeaway is: - -> As soon as the XVF3800 is selected as the input device in `config.json`, the Linux -> Voice Assistant can use it as a high‑quality microphone front end with no additional -> code changes. From 7abceb946e8e7e8fe404cbc0bb05a16ac395b65c Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:47:56 -0800 Subject: [PATCH 57/59] Delete docs/xvf3800_usb_led_testing.md Obsolete --- docs/xvf3800_usb_led_testing.md | 158 -------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 docs/xvf3800_usb_led_testing.md diff --git a/docs/xvf3800_usb_led_testing.md b/docs/xvf3800_usb_led_testing.md deleted file mode 100644 index 86ba69a..0000000 --- a/docs/xvf3800_usb_led_testing.md +++ /dev/null @@ -1,158 +0,0 @@ -# XVF3800 USB LED and Mute Button Support in `linux-voice-assistant` - -The XVF3800 branch now includes **USB-based LED ring support** using the XVF3800’s legacy LED control commands (`LED_EFFECT`, `LED_BRIGHTNESS`, `LED_SPEED`, `LED_COLOR`). This post outlines how to pull the branch, refresh your virtualenv, and enable XVF3800 support in `config.json`. I fully expect bugs and you should too. Let me know if it works first try and I will by some lottery tickets as well. - ---- - -## 1. Get the XVF3800 Branch - -If you **already have** the repo: - -```bash -cd ~/linux-voice-assistant -git fetch origin -git switch XVF3800 # or: git checkout xvf3800 -git pull -``` - -If you’re **cloning fresh**: - -```bash -cd ~ -git clone --branch XVF3800 --single-branch https://github.com/imonlinux/linux-voice-assistant.git -``` - ---- - -## 2. Install/Refresh the Virtualenv (New Dependencies) - -The XVF3800 backend uses **pyusb** and system libusb. - -From the project root: - -```bash -cd ~/linux-voice-assistant - -# remove the linux_voice_assistant.egg-info folder if it exists -rm -rf linux_voice_assistant.egg-info - -# Create venv if you don't already have one -script/setup -``` -On Debian/RPi OS, make sure libusb is installed: - - -```bash -sudo apt update -sudo apt install libusb-1.0-0 -``` - -No extra `hidapi` is required for the LED integration (this path is pure pyusb). - -## 3. Copy the udev rule for the XVF3800 - -On the Pi: - -```bash -sudo cp ~linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/99-respeaker-xvf3800.rules -``` - -This does: - -- Matches the XVF3800 by VID/PID (2886:001a) - -- Sets permissions to 0660 - -- Assigns it to the plugdev group - -- Tags it for desktop access (uaccess) - -### Add user (pi) to the plugdev group -**use the user name that you have created if not pi** - -```bash -sudo usermod -aG plugdev pi -``` - -Then log out and back in (or reboot) so the new group membership takes effect. - -### Reload udev rules and replug the device - -If you didn't reboot, reload the udev rules: - -```bash -sudo udevadm control --reload-rules -sudo udevadm trigger -``` - -Then unplug/replug the XVF3800 USB cable. - -You can sanity-check permissions with: - -```bash -ls -l /dev/bus/usb/*/* -``` - -Look for the device entry that corresponds to 2886:001a (you can pair it with lsusb), and confirm it’s root plugdev with crw-rw----. - - -## 4. Enable XVF3800 Support in `config.json` - -Edit `linux_voice_assistant/config.json` and set the `led` block to use the XVF3800 USB backend. If you want to test the mute button, add the following button section as well. See the `linux_voice_assistant/config.json.example` file for additional options. - -Minimal example: - -```jsonc -{ - "app": { - "name": "Linux Voice Assistant XVF3800" - }, - "led": { - "enabled": true, - "led_type": "xvf3800", - "interface": "usb" - }, - "button": { - "enabled": true, - "mode": "xvf3800" - }, - "mqtt": { - "host": "192.168.1.2", - "port": 1883, - "username": "mqtt_username", - "password": "mqtt_password" - } -} -``` - ---- - -## 5. Run the Assistant with XVF3800 LEDs - -Stop any running systemd units: - -```bash -systemctl --user stop linux-voice-assistant.service -``` - - -From the project root: - -```bash -cd ~/linux-voice-assistant -script/run --debug -``` - -With the XVF3800 branch + config above, the **USB LED ring** should now: - -- Show the configured **idle / listening / thinking / responding / error** effects -- Turn **solid dim red** when the mic is muted -- Track changes coming from the MQTT LED state entities - -The mute button should now: - -- Keep hardware mute & software/LVA mute fully in sync. -- No short/long press differentiation: - One physical control, one responsibility: mute/unmute. - -`Wake/stop is still done via wake word or a separate GPIO button (you can still wire a standalone Pi button and run mode="gpio" in parallel if desired).` From 68c79304054f5453777818bb5e21ea21c8b4c7e1 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:30:45 -0800 Subject: [PATCH 58/59] Update linux-voice-assistant-2mic-install.md typo --- docs/linux-voice-assistant-2mic-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/linux-voice-assistant-2mic-install.md b/docs/linux-voice-assistant-2mic-install.md index 80066d7..c567775 100644 --- a/docs/linux-voice-assistant-2mic-install.md +++ b/docs/linux-voice-assistant-2mic-install.md @@ -608,7 +608,7 @@ alexa_v0.1.tflite -> Alexa hey_jarvis_v0.1.tflite -> Hey Jarvis hey_mycroft_v0.1.tflite -> Hey Mycroft hey_rhasspy_v0.1.tflite -> Hey Rhasspy -ok_nabu_v0.1.tflite -> OK Nabo **(I had to say OK Nobu)** +ok_nabu_v0.1.tflite -> OK Nabu **(I had to say OK Nobu)** ``` Additional community provided OWW models available from this repository: From 0d6b87739a741e9b16f61d2f29d90ff5703ace73 Mon Sep 17 00:00:00 2001 From: imonlinux <39863321+imonlinux@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:33:13 -0800 Subject: [PATCH 59/59] Update linux-voice-assistant-xvf3800.md Typos and corrections --- docs/linux-voice-assistant-xvf3800.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/linux-voice-assistant-xvf3800.md b/docs/linux-voice-assistant-xvf3800.md index 3604ddf..e040982 100644 --- a/docs/linux-voice-assistant-xvf3800.md +++ b/docs/linux-voice-assistant-xvf3800.md @@ -28,7 +28,7 @@ No custom kernel drivers are required. Plug it in, setup the device in LVA’s ` - To use the advanced LED effects, upgrade to the latest XVF3800 firmware (respeaker_xvf3800_usb_dfu_firmware_v2.0.7.bin as of this writing). -- https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY/blob/master/xmos_firmwares/dfu_guide.md - Tested and works on a Raspberry Pi Zero 2W, but I don't recommend attempting to flash the firmware with this device. -- Pipewire (or PluseAudio) installed and tested. I haven't tested the XVF3800 with ALSA. +- PipeWire (or PulseAudio) installed and tested. I haven't tested the XVF3800 with ALSA. --- @@ -40,7 +40,8 @@ sudo apt upgrade sudo apt install build-essential git \ libmpv-dev mpv python3-dev python3-venv \ pulseaudio-utils pipewire wireplumber \ - pipewire-audio libspa-0.2-modules libusb-1.0-0 + pipewire-audio libspa-0.2-modules libusb-1.0-0 \ + dbus-user-session sudo reboot ``` @@ -59,7 +60,7 @@ cd linux-voice-assistant If you already have the repository, you can: ```bash -cp ~/linux-voice-assistant/linux_voice_assistant/config.json ~/ +cp ~/linux-voice-assistant/linux_voice_assistant/config.json ~/config.json cd ~/linux-voice-assistant git pull cp ~/config.json ~/linux-voice-assistant/linux_voice_assistant/config.json @@ -68,7 +69,9 @@ cp ~/config.json ~/linux-voice-assistant/linux_voice_assistant/config.json Implement the UDEV rule to prevent the XVF3800 from taking naps and to allow user access to the device ```bash -cp ~/linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/ +sudo cp ~/linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules +sudo udevadm trigger ``` @@ -121,10 +124,10 @@ Add or update the following sections (Recommended settings): "poll_interval_seconds": 0.15 }, "mqtt": { - "host": "192.168.0.100", + "host": "192.168.1.2", "port": 1883, - "username": "mqtt", - "password": "Ss1552157" + "username": "mqtt_server", + "password": "mqtt_password" }, "audio": { "volume_sync": true,