152 Commits
Author SHA1 Message Date
imonlinux bd62a3b77c PulseAudio User Mode Unit MWW 2025-10-05 16:55:39 -07:00
imonlinux 0d4f03b1b0 PulseAudio User Mode Unit File
User mode is so much easier than system mode pulseaudio.
2025-10-05 16:53:06 -07:00
imonlinux 50f1e3f92f Update install_pipewire.md 2025-09-30 13:28:14 -07:00
imonlinux 383450a090 Added depencency for Grove 2025-09-29 20:59:38 -07:00
imonlinux 01a40e641f Add support for Grove Interface
Add Grove interface support for APA102 LEDs instead of the built in spi.
2025-09-29 19:41:28 -07:00
imonlinux bf17dfd670 Add packages to support echo cancellation 2025-09-28 20:50:52 -07:00
imonlinux e75fa71d06 Update README.md 2025-09-28 20:44:35 -07:00
imonlinux b06d027ae6 Update README.md 2025-09-28 20:36:14 -07:00
imonlinux ee0cfb68c6 Update gen-esphome-avahi.sh 2025-09-28 20:13:39 -07:00
imonlinux 86edd1134d Update install_pipewire.md 2025-09-28 19:33:43 -07:00
imonlinux 14779b4439 Refactored file with support for PW, PA, ALSA 2025-09-27 22:05:47 -07:00
imonlinux 74032adb9b Update mpv_player.py to support PipeWire
Updated the mpv_player.py to support PipeWire native devices (pipewire/default) instead of using the pipewire-pulse compatibility.
2025-09-27 21:19:06 -07:00
imonlinux 6b9a1e1e28 Change output audio device to pipewire/default
Change to native pipewire instead of the pipewire-pulse compatibility.
2025-09-27 21:16:58 -07:00
imonlinux d74045e349 Change output audio device to pipewire/default
Change to native pipewire instead of the pipewire-pulse compatibility.
2025-09-27 21:15:22 -07:00
imonlinux 2c44521c63 Add PipeWire info remove docker task 2025-09-27 20:53:00 -07:00
imonlinux bfae803279 Update with PipeWire info 2025-09-27 20:51:19 -07:00
imonlinux 6ad23f8319 Update linux-voice-assistant-2mic-install.md 2025-09-27 09:00:59 -07:00
imonlinux 36e353a6df PipeWire, PulseAudio, ALSA, OWW, MWW
Fixed section numbers.
2025-09-27 08:56:53 -07:00
imonlinux 76746e4262 Update linux-voice-assistant-2mic-install.md
Cleanup and collapsible sections to reduce confusion.
2025-09-27 08:52:58 -07:00
imonlinux aee8c56de3 Update linux-voice-assistant-2mic-install.md
Added requirement to enable linger for user mode services
2025-09-27 08:19:24 -07:00
imonlinux ed278ae3a2 Rename install_pipewire to install_pipewire.md 2025-09-26 22:40:13 -07:00
imonlinux 00c8541ed8 Update linux-voice-assistant-2mic-install.md
Add instructions for setting up PipeWire and User Mode Systemd service.
2025-09-26 22:39:43 -07:00
imonlinux 59e993d422 Create install_pipewire 2025-09-26 22:05:10 -07:00
imonlinux 4f8f4e2ebc Add user mode service files for pipewire support 2025-09-26 20:14:58 -07:00
imonlinux 99ecb1f186 Changed avahi service
Changed to Platform to LINUX and pulled in uname -m for the board.

This is cosmetic as it doesn't affect detection.
2025-09-25 09:36:13 -07:00
imonlinux e50f463c58 Avahi service file fixed
HA Discovery working
2025-09-24 21:15:35 -07:00
imonlinux 156b6473d5 Remove OWW requirement 2025-09-24 21:02:53 -07:00
imonlinux 7a54f5ce38 Remove OWW Dependency 2025-09-24 21:01:34 -07:00
imonlinux 1cc4e43ee5 Fix avahi service file 2025-09-24 21:00:11 -07:00
imonlinux 2564d1a745 Add HAL and Marvin Wakewords 2025-09-20 18:28:47 -07:00
imonlinux cd83ee018f Add HAL and Marvin led files 2025-09-20 18:21:21 -07:00
imonlinux b4f736a79b Update install_pulseaudio.md 2025-09-15 20:10:54 -07:00
imonlinux aa83dcc601 Merge pull request #18 from imonlinux/mpv_player.py-disable-watchdog
Disabled watchdog for pulseaudio.
2025-09-14 18:44:40 -07:00
imonlinux b982f9f990 Disabled watchdog for pulseaudio.
Will reintroduce this function at a later date if needed.
2025-09-14 18:37:58 -07:00
imonlinux feb724edd2 Merge pull request #17 from imonlinux/mpv_player.py-revert
Introduced a bug that fires during HA registration
2025-09-14 18:02:33 -07:00
imonlinux 7654d43e09 Introduced a bug that fires during HA registration 2025-09-14 18:02:19 -07:00
imonlinux 8f8a041ecf Add files via upload 2025-09-14 16:48:42 -07:00
imonlinux ef75951ade Update linux-voice-assistant-2mic-install.md 2025-09-14 16:04:44 -07:00
imonlinux 9782c17913 Update README.md 2025-09-14 15:31:29 -07:00
imonlinux 192ca11b1b Update README.md 2025-09-14 15:25:02 -07:00
imonlinux 2773412898 Update linux-voice-assistant-2mic-install.md 2025-09-14 15:22:42 -07:00
imonlinux 368876f59d Update README.md 2025-09-14 15:05:35 -07:00
imonlinux d606364f84 Merge pull request #14 from imonlinux/HA_LVA_Discovery
HA LVA Discovery
2025-09-14 12:23:18 -07:00
imonlinux 0289fd5a4c Update linux-voice-assistant-2mic-install.md
Updated the tutorial to include optional avahi config for HA discovery of the LVA.
2025-09-14 12:17:53 -07:00
imonlinux e17e13c5dd Add LVA service drop-in for avahi
This updates the linux-voice-assistant.service to require and wait for avahi to start. Independent of the systemd unit being used to start LVA.
2025-09-14 12:04:34 -07:00
imonlinux fa82a56831 Add script to create and install avahi service unit
Intended to setup Zeroconf/mDNS for HA discovery
2025-09-14 12:01:14 -07:00
imonlinux 4610c546ce Merge pull request #13 from imonlinux/bug_fix_mpv_player.py
Fix watchdog misfire in mpv_player.py
2025-09-14 11:55:08 -07:00
imonlinux 38fc8227f3 Fix watchdog misfire in mpv_player.py
The current watchdog is a simple “N-seconds since play()” timer, so it can fire during legit playback (longer TTS, slower network). Convert it to a no-progress watchdog that only trips if mpv makes no progress for N seconds.
2025-09-14 11:54:54 -07:00
imonlinux ceb64f89a1 OWW model tester
attempts to find models that would stop OWW from starting
2025-09-13 23:17:31 -07:00
imonlinux 2eb46a494e Delete wyoming-openwakeword/verify_oww_models.sh 2025-09-13 23:16:21 -07:00
imonlinux cda879ebd3 Update linux-voice-assistant-2mic-install.md 2025-09-13 23:14:13 -07:00
imonlinux 2e93e10496 Merge pull request #12 from imonlinux/Pulse_Audio_ALSA_OWW_MWW
Pulse audio alsa oww mww
2025-09-13 21:41:19 -07:00
imonlinux 3d799af40b Debugging utility
Not required by the project.
2025-09-13 21:17:59 -07:00
imonlinux 6e19c4db2d Delete service/pa-linux-voice-assistant.service 2025-09-13 21:13:41 -07:00
imonlinux b685769f37 Delete service/linux-voice-assistant.service 2025-09-13 21:13:23 -07:00
imonlinux 9e6b12d771 Update linux-voice-assistant-2mic-install.md 2025-09-13 21:12:54 -07:00
imonlinux 72dc42a3e2 Update README.md for PA support 2025-09-13 20:16:59 -07:00
imonlinux a0c5af9d4c Modifed mpv_player.py to support PA and ALSA
1. **`linux_voice_assistant/mpv_player.py`**
   - Supports both **PulseAudio** and **ALSA** backends.
   - Device strings now accept:
     - Pulse: `pulse/<PA SINK NAME>` (e.g. `pulse/alsa_output.platform-soc_sound.stereo-fallback`)
     - ALSA: `alsa/plughw:CARD=seeed2micvoicec,DEV=0`
     - `default`: tries Pulse first, then ALSA.
   - Added **fast end-of-playback detection** using **either** `eof-reached` **or** `idle-active` (fixes the “hang until watchdog” after short clips).
   - Added a **watchdog** (default **8s**) to fail fast if playback never starts/finishes.
   - Pulse-friendly defaults: `audio-samplerate=44100`, `audio-channels=stereo`, `keep-open=no`, `network-timeout=7`.
   - mpv logging bridged to Python logging; level set by env **`LVA_MPV_MSG_LEVEL`** (default `all=warn`).
2025-09-13 19:58:15 -07:00
imonlinux 8d179b371c Add files via upload 2025-09-13 19:55:05 -07:00
imonlinux 8c86546065 Update install_pulseaudio.md 2025-09-13 19:50:12 -07:00
imonlinux fe2cdb9316 Update install_pulseaudio.md 2025-09-13 19:45:26 -07:00
imonlinux e545936c48 Update install_pulseaudio.md 2025-09-13 19:03:52 -07:00
imonlinux c487dc307c Update install_pulseaudio.md 2025-09-13 11:36:18 -07:00
imonlinux 58aa39629d Merge pull request #11 from imonlinux/imonlinux-patch-1
Add PA service file
2025-09-13 11:34:17 -07:00
imonlinux dc27b41d08 Add PA service file 2025-09-13 11:33:55 -07:00
imonlinux 72bde398b7 Update install_pulseaudio.md 2025-09-13 11:09:49 -07:00
imonlinux 26d33f7943 Update install_pulseaudio.md 2025-09-13 10:47:11 -07:00
imonlinux 8c9052da41 Update linux-voice-assistant-2mic-install.md 2025-09-12 17:53:32 -07:00
imonlinux 0c1085150d Update pa-linux-voice-assistant.service 2025-09-12 17:42:42 -07:00
imonlinux 49e43e51e1 Update install_pulseaudio.md 2025-09-12 17:30:21 -07:00
imonlinux c8a8cde934 Update install_pulseaudio.md 2025-09-12 17:28:43 -07:00
imonlinux 4d11e252c9 Delete service/pulseaudio.service 2025-09-12 17:25:32 -07:00
imonlinux 8f9cce3f02 Add files via upload 2025-09-12 17:24:52 -07:00
imonlinux a309e5ffea Update pa-linux-voice-assistant.service 2025-09-12 14:18:15 -07:00
imonlinux cb6ae03548 Update linux-voice-assistant-2mic-install.md 2025-09-12 14:02:13 -07:00
imonlinux 1529a02d68 Update linux-voice-assistant-2mic-install.md 2025-09-12 14:00:17 -07:00
imonlinux d159d57b5f Update linux-voice-assistant-2mic-install.md 2025-09-12 13:58:22 -07:00
imonlinux 49f692e6ab Update linux-voice-assistant-2mic-install.md 2025-09-12 13:54:22 -07:00
imonlinux 758436be08 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:53:50 -07:00
imonlinux bf74160528 Merge pull request #10 from imonlinux/imonlinux-patch-1
Add tutorial for PA and alternate service file
2025-09-12 13:33:25 -07:00
imonlinux 734c0b7cf4 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:29:36 -07:00
imonlinux 694251de64 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:27:41 -07:00
imonlinux bf241cfa57 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:25:33 -07:00
imonlinux 6e54a7decd Add files via upload 2025-09-12 13:24:30 -07:00
imonlinux 9579f61c44 Add files via upload 2025-09-12 13:16:23 -07:00
imonlinux 5a4a496322 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:13:28 -07:00
imonlinux ec7de2312a Update linux-voice-assistant-2mic-install.md 2025-09-12 13:11:50 -07:00
imonlinux e3b32579ae Update linux-voice-assistant-2mic-install.md 2025-09-12 13:08:07 -07:00
imonlinux 26a7ecbea2 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:06:16 -07:00
imonlinux bdcfad0e00 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:05:10 -07:00
imonlinux 09fd7f8534 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:04:06 -07:00
imonlinux 5e805b8722 Update linux-voice-assistant-2mic-install.md 2025-09-12 13:01:35 -07:00
imonlinux 4c25739b3c Update linux-voice-assistant-2mic-install.md 2025-09-12 13:00:13 -07:00
imonlinux be61375b43 Update linux-voice-assistant-2mic-install.md 2025-09-12 12:59:15 -07:00
imonlinux 6233987d89 Add files via upload 2025-09-12 12:34:53 -07:00
imonlinux 30683cf718 Update linux-voice-assistant-2mic-install.md 2025-09-12 11:01:11 -07:00
imonlinux 0c21b8d8ab Update linux-voice-assistant-2mic-install.md 2025-09-11 21:23:23 -07:00
imonlinux 55cef9e915 Merge pull request #9 from imonlinux/imonlinux-patch-1
Update linux-voice-assistant.service
2025-09-11 21:22:22 -07:00
imonlinux 8411c09acb Update linux-voice-assistant.service
Changed the default wake word to "alexa" as I'm having issue with "ok_nabu".
2025-09-11 21:22:01 -07:00
imonlinux 7817e7a982 Update linux-voice-assistant-2mic-install.md 2025-09-11 21:20:46 -07:00
imonlinux f2f1be206e Update linux-voice-assistant-2mic-install.md 2025-09-11 21:09:52 -07:00
imonlinux 65212d3999 Update linux-voice-assistant-2mic-install.md 2025-09-11 21:07:25 -07:00
imonlinux 6ca1ee0717 Merge pull request #8 from imonlinux/imonlinux-patch-1
Add OWW wake word list to HA
2025-09-11 20:48:14 -07:00
imonlinux fa1492d410 Add choose wake word in HA
Adds the OWW model names using friendly names
2025-09-11 20:47:16 -07:00
imonlinux fa9867b4ba Add files via upload 2025-09-11 20:45:56 -07:00
imonlinux 07eb4ed3bf Add files via upload 2025-09-11 20:45:18 -07:00
imonlinux e72635978f Merge pull request #7 from imonlinux/imonlinux-patch-1
Imonlinux patch 1
2025-09-10 12:41:04 -07:00
imonlinux 364aef1a74 Delete linux_voice_assistant/wakeword_indicator.py 2025-09-10 12:40:19 -07:00
imonlinux 9ccd186ae4 Revert api_server.py changes 2025-09-10 12:39:10 -07:00
imonlinux 13b05a82f0 Merge pull request #6 from imonlinux/imonlinux-patch-1
Add wakeword engine indicator for HA
2025-09-10 10:04:59 -07:00
imonlinux 0337d28bce Add wakeword engine indicator for HA 2025-09-10 10:04:03 -07:00
imonlinux a07a3aa618 Update linux-voice-assistant-2mic-install.md 2025-09-09 23:09:11 -07:00
imonlinux f2f34a9604 Merge pull request #5 from imonlinux/imonlinux-patch-2
Add OWW model validation tool
2025-09-09 23:06:54 -07:00
imonlinux 15836c20de Add OWW model validation tool 2025-09-09 23:06:38 -07:00
imonlinux 0294c7de20 Update wyoming-openwakeword.service 2025-09-09 22:33:04 -07:00
imonlinux 38539fc260 Update linux-voice-assistant-2mic-install.md 2025-09-09 21:26:24 -07:00
imonlinux f7caecec7a Update linux-voice-assistant-2mic-install.md 2025-09-09 21:19:37 -07:00
imonlinux 8bbd57bca2 Update linux-voice-assistant.service 2025-09-09 21:11:43 -07:00
imonlinux 8308a845cd Update linux-voice-assistant-2mic-install.md 2025-09-09 21:10:59 -07:00
imonlinux 94c61b5dae Update linux-voice-assistant-2mic-install.md 2025-09-09 21:05:55 -07:00
imonlinux a439f9ac60 Update README.md 2025-09-09 21:00:32 -07:00
imonlinux 59ed20bf61 Update README.md 2025-09-09 20:56:48 -07:00
imonlinux d3a020b8f1 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:56:17 -07:00
imonlinux 2c66fa703a Update README.md 2025-09-09 20:53:36 -07:00
imonlinux de6ab73bae Update linux-voice-assistant-2mic-install.md 2025-09-09 20:33:44 -07:00
imonlinux b7e40bf518 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:30:44 -07:00
imonlinux 8254e5fd7e Update linux-voice-assistant-2mic-install.md 2025-09-09 20:30:08 -07:00
imonlinux 7dfe1e1202 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:29:51 -07:00
imonlinux 8540242f8a Update linux-voice-assistant-2mic-install.md 2025-09-09 20:28:50 -07:00
imonlinux c18edd445c Update linux-voice-assistant-2mic-install.md 2025-09-09 20:23:35 -07:00
imonlinux 9b0369df94 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:23:15 -07:00
imonlinux afc0fdf22f Update linux-voice-assistant-2mic-install.md 2025-09-09 20:22:47 -07:00
imonlinux 837d20d73b Update linux-voice-assistant-2mic-install.md 2025-09-09 20:21:48 -07:00
imonlinux cade885191 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:21:27 -07:00
imonlinux 5da4347a77 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:20:21 -07:00
imonlinux 12303da1ce Update linux-voice-assistant-2mic-install.md 2025-09-09 20:20:03 -07:00
imonlinux 49dfc161d5 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:19:22 -07:00
imonlinux 667d77d4db Update linux-voice-assistant-2mic-install.md 2025-09-09 20:16:57 -07:00
imonlinux d08a39aaf7 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:14:20 -07:00
imonlinux f4e8ed705e Update linux-voice-assistant-2mic-install.md 2025-09-09 20:12:26 -07:00
imonlinux bb48e92bc0 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:10:37 -07:00
imonlinux dab8e5bd8d Update linux-voice-assistant-2mic-install.md 2025-09-09 20:10:03 -07:00
imonlinux 643b296a56 Update linux-voice-assistant-2mic-install.md 2025-09-09 20:09:42 -07:00
imonlinux cee6228e11 Merge pull request #4 from imonlinux/imonlinux-patch-1
Add tutorial for install and config with reSpeaker
2025-09-09 20:08:16 -07:00
imonlinux ec5a8391ef Add tutorial for install and config with reSpeaker
Added tutorial and wyoming-openwakeword.service file.
2025-09-09 20:07:57 -07:00
imonlinux ec29ce7727 Merge pull request #3 from imonlinux/imonlinux-patch-1
Fix OWW model to default "ok_nabu"
2025-09-09 19:17:23 -07:00
imonlinux 5ea57ef822 Fix OWW model to default "ok_nabu" 2025-09-09 19:16:55 -07:00
imonlinux c5ddf05bb7 Merge pull request #2 from imonlinux/imonlinux-patch-1
Add reSpeaker2mic installer OWW recommends.txt
2025-09-09 17:57:37 -07:00
imonlinux fe708ffc85 Add reSpeaker2mic installer OWW recommends.txt 2025-09-09 17:51:54 -07:00
imonlinux 3dddc504db fixed pathing error 2025-09-09 17:11:18 -07:00
imonlinux 33220ad263 Merge pull request #1 from imonlinux/imonlinux-patch-1
ChatGPT modified code for the option of using OWW
2025-09-09 16:53:55 -07:00
imonlinux 5d1df036f8 ChatGPT modified code for the option of using OWW
If the arguments for OWW are included, microwakeword will be disabled and OWW will be used for detection. Requires a running instance of OWW on the local machine using port 10400. If the OWW arguments are excluded, microwakeword is used for detection.

--wake-uri 'tcp://127.0.0.1:10400' \
  --wake-word-name 'marvin'
2025-09-09 16:51:44 -07:00
53 changed files with 3365 additions and 100 deletions
+29 -19
View File
@@ -1,17 +1,28 @@
# Linux Voice Assistant
Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
Experimental Linux voice assistant (LVA) for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
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).
### What's working:
- This fork introduces the ability to use Wyoming OpenWakeWord (OWW) instead of MicroWakeWord (MWW).
- Now supports **ALSA, PulseAudio and PipeWire** playback backends using the updated `linux_voice_assistant/mpv_player.py`.
- You can choose between ALSA, PulseAudio, or PipeWire with either MWW or OWW simply by enabling the matching systemd service file.
- You can change the OWW wake word within HA after the VLA is registered. (defaults to the wake word defind in the systemd config file)
- Home Assistant successfully detects the LVA when Avahi is setup according to the tutorial.
## Installation
Install system dependencies (`apt-get`):
* `libportaudio2` or `portaudio19-dev` (for `sounddevice`)
* `libportaudio2` (for `sounddevice`)
* `build-essential` (for `pymicro-features`)
* `libmpv-dev` (for `python-mpv`)
* `mpv` (for testing)
* `libmpv-dev` (for building spidev)
Clone and install project:
@@ -25,25 +36,8 @@ script/setup
Use `script/run` or `python3 -m linux_voice_assistant`
You must specify `--name <NAME>` with a name that will be available in Home Assistant.
See `--help` for more options.
### Microphone
Use `--audio-input-device` to change the microphone device. Use `python3 -m sounddevice` to see the available PortAudio devices.
The microphone device **must** support 16Khz mono audio.
### Speaker
Use `--audio-output-device` to change the speaker device. Use `mpv --audio-device=help` to see the available MPV devices.
## Wake Word
Change the default wake word with `--wake-model <id>` where `<id>` is the name of a model in the `wakewords` directory. For example, `--wake-model hey_jarvis` will load `wakewords/hey_jarvis.tflite` by default.
## Connecting to Home Assistant
1. In Home Assistant, go to "Settings" -> "Device & services"
@@ -52,6 +46,22 @@ Change the default wake word with `--wake-model <id>` where `<id>` is the name o
4. Enter the IP address of your voice satellite with port 6053
5. Click "Submit"
## ToDo:
* Implement echo-cancellation filter in PipeWire. (Improves wake word detection when audio is being played)
* Merge jianyu-li's PR from source project to add mute switch function in this branch
* ~~Configure LVA to advertise on Zeroconf/mDNS via Avahi for HA to auto detect (in progress)~~
* Implement a single LVA systemd unit file that can be addapted using profiles and drop-ins (in progress)
* Implement OWW model validation checks and error handling so that a bad model doesn't crash OWW
* Make the selection of the right ALSA ar PA device more scripted
* ~~Make a Docker of the project~~ Already done in parent repo.
* Add sensor entity that displays which wake word engine is being used for HA
* Could this be an DEB package?
* Implement Stop for OWW
* Stretch goal: create a smart installer full of validation and sanity checks
<!-- Links -->
[homeassistant]: https://www.home-assistant.io/
[esphome]: https://esphome.io/
[wyoming]: https://github.com/rhasspy/wyoming-openwakeword/
[future proof home]: https://github.com/FutureProofHomes/wyoming-enhancements/
+92
View File
@@ -0,0 +1,92 @@
>
> Install Pipewire On Your Linux Voice Satellite
>
## 1. Connect to your Pi over SSH using the username/password you configured during flashing:
```sh
ssh <your_username>@<pi_IP_address>
```
## 2. If already installed Disable/Stop the entire the Linux Voice Assistant service. If not installed, proceed to step (3.):
```sh
sudo systemctl disable --now linux-voice-assistant.service
sudo rm /etc/systemd/system/linux-voice-assistant.service
```
## 3. Install Pipewire packages and necessary PulseAudio utilities:
```sh
sudo apt-get update
sudo apt-get install \
pulseaudio-utils \
pipewire wireplumber \
pipewire-audio libspa-0.2-modules
```
## 4. Reboot your Pi:
```sh
sudo reboot -h now
```
## 5. SSH back in to your Pi. (See step 1.)
## 6. Ensure Pipewire is runnining in user mode.
```sh
systemctl --user status pipewire.service
systemctl --user status pipewire-pulse.service
```
## 7. (OPTIONAL) If plugging speakers into the headphone jack of the ReSpeaker, set the correct PulseAudio Sink Port:
Run `pactl list sinks` and scroll to the bottom and notice your Active Port is probably "analog-output-speaker". Run the command below to output audio through the 3.5mm headphone jack.
```sh
pactl set-sink-port alsa_output.platform-soc_sound.stereo-fallback "analog-output-headphones"
```
**Please note that if you are setting this up on a Raspberry Pi 3B or 4B, you need an additional step at this point to make it route the audio through the 2mic hat (and likely the 4mic one as well). Run the following command to do this.**
```sh
pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
```
## 8. Test and make sure you can hear the wav file.:
```sh
mpv --ao=pipewire --audio-device=default --audio-samplerate=44100 /usr/share/sounds/alsa/Front_Center.wav
```
If volume is low, set it to 150%.
```sh
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 150%
```
**Note that if you have a Raspbery Pi 3B or 4B, this may or may not seem to work, but it will output on the hat as intended.**
## 9. Reboot your Pi: (see step 4)
## 10. Done! Return to the tutorial to continue the Linux Voice Assistant install.
+239
View File
@@ -0,0 +1,239 @@
## Install PulseAudio On Your Wyoming Voice Satellite
> Courtesy of https://github.com/FutureProofHomes/wyoming-enhancements/blob/master/snapcast/docs/2_install_pulseaudio.md with minor modifications
## 1. Connect to your Pi over SSH using the username/password you configured during flashing:
```sh
ssh <your_username>@<pi_IP_address>
```
## 2. If already installed Disable/Stop the entire the Linux Voice Assistant service temporarily. If not installed, proceed to step (3.):
```sh
sudo systemctl disable --now linux-voice-assistant.service
```
## 3. Install PulseAudio drivers and necessary utilities:
```sh
sudo apt-get update
sudo apt-get install \
pulseaudio \
pulseaudio-utils \
pamixer
```
## 4. Reboot your Pi:
```sh
sudo reboot -h now
```
## 5. SSH back in to your Pi. (See step 1.)
## 6. Ensure PulseAudio is runnining in "system-wide mode". Per the [official instructions](https://www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/SystemWide/) we first need to stop some existing services to be safe:
```sh
sudo systemctl --global disable pulseaudio.service pulseaudio.socket
```
## 7. It is also advisable to set `autospawn = no` in `/etc/pulse/client.conf`:
```sh
sudo sed -i.bak \
-e '$a### Disable autospawn' \
-e '$aautospawn = no' \
/etc/pulse/client.conf
```
## 8. Create the `PulseAudio.service`:
```sh
sudo cp ~/linux-voice-assistant/service/pulseaudio.service /etc/systemd/system/pulseaudio.service
```
## 9. Start the `PulseAudio.service`. This will require you to type in your password a couple times.
```sh
systemctl --system enable pulseaudio.service
```
```sh
systemctl --system start pulseaudio.service
```
## 10. Ensure pi user is in the `pulse-access` group:
***If you aren't using the default "pi" user, put your user in here instead of "pi".***
```sh
sudo sed -i '/^pulse-access:/ s/$/root,pi/' /etc/group
```
## 11. Reboot your Pi: (see step 4)
## 12. (OPTIONAL) If plugging speakers into the headphone jack of the ReSpeaker, set the correct PulseAudio Sink Port:
Run `pactl list sinks` and scroll to the bottom and notice your Active Port is probably "analog-output-speaker". Run the command below to output audio through the 3.5mm headphone jack.
```sh
pactl set-sink-port 1 "analog-output-headphones"
```
**Please note that if you are setting this up on a Raspberry Pi 3B or 4B, you need an additional step at this point to make it route the audio through the 2mic hat (and likely the 4mic one as well). Run the following command to do this.**
```sh
pactl set-default-sink alsa_output.platform-soc_sound.stereo-fallback
```
## 13. Test and make sure you can hear the wav file.:
```sh
paplay /usr/share/sounds/alsa/Front_Center.wav
```
**Note that if you have a Raspbery Pi 3B or 4B, this may or may not seem to work, but it will output on the hat as intended.**
## 14. The following modifications are specifically required for LVA's mpv_player.py to work with PA. If you do not have a working PA at this point, do not proceed.:
Backup current PA daemon.conf file.
```bash
sudo cp /etc/pulse/daemon.conf{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/daemon.conf file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*exit-idle-time\s*=.*$|exit-idle-time = -1|' \
-e 's|^[#;[:space:]]*resample-method\s*=.*$|resample-method = trivial|' \
-e 's|^[#;[:space:]]*flat-volumes\s*=.*$|flat-volumes = no|' \
-e 's|^[#;[:space:]]*default-sample-rate\s*=.*$|default-sample-rate = 44100|' \
-e 's|^[#;[:space:]]*alternate-sample-rate\s*=.*$|alternate-sample-rate = 48000|' \
-e 's|^[#;[:space:]]*default-fragment-size-msec\s*=.*$|default-fragment-size-msec = 15|' \
/etc/pulse/daemon.conf
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for kv in \
'exit-idle-time = -1' \
'resample-method = trivial' \
'flat-volumes = no' \
'default-sample-rate = 44100' \
'alternate-sample-rate = 48000' \
'default-fragment-size-msec = 15'; do
k="${kv%% =*}"
sudo grep -Eq "^[[:space:]]*${k}[[:space:]]*=" /etc/pulse/daemon.conf || echo "$kv" | sudo tee -a /etc/pulse/daemon.conf >/dev/null
done
```
Backup current PA system.pa file.
```bash
sudo cp /etc/pulse/system.pa{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/system.pa file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-udev-detect)([[:space:]].*)?$|\1 tsched=0|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-native-protocol-unix.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-default-device-restore.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-always-sink.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-position-event-sounds.*)$|\1|' \
/etc/pulse/system.pa
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for m in \
'load-module module-native-protocol-unix' \
'load-module module-default-device-restore' \
'load-module module-always-sink' \
'load-module module-position-event-sounds'; do
sudo grep -Eq "^[[:space:]]*$m(\s|$)" /etc/pulse/system.pa || echo "$m" | sudo tee -a /etc/pulse/system.pa >/dev/null
done
```
Backup current PA default.pa file.
```bash
sudo cp /etc/pulse/default.pa{,.bak.$(date +%s)}
```
Update the PA /etc/pulse/default.pa file with required change. Replaces if present (commented or not).
```bash
sudo sed -i -E \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-udev-detect)([[:space:]].*)?$|\1 tsched=0|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-native-protocol-unix.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-default-device-restore.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-always-sink.*)$|\1|' \
-e 's|^[#;[:space:]]*(load-module[[:space:]]+module-intended-roles.*)$|\1|' \
/etc/pulse/default.pa
```
Just in case the values were missing, may not be needed but shouldn't hurt.
```bash
for m in \
'load-module module-native-protocol-unix' \
'load-module module-default-device-restore' \
'load-module module-always-sink' \
'load-module module-intended-roles'; do
sudo grep -Eq "^[[:space:]]*$m(\s|$)" /etc/pulse/default.pa || echo "$m" | sudo tee -a /etc/pulse/default.pa >/dev/null
done
```
Optional since LVA handles duck/unduck: if you enabled role-ducking explicitly in system.pa, you can comment it out with:
```bash
sudo sed -i -E 's|^([[:space:]]*)load-module[[:space:]]+module-role-ducking\b|# \0|' /etc/pulse/system.pa
```
## 15. Restart PA and verify
```bash
sudo systemctl restart pulseaudio.service
```
Test mpv can ouput to PA with current configs:
```bash
mpv --ao=pulse --audio-device=default --audio-samplerate=44100 /usr/share/sounds/alsa/Front_Center.wav
```
If you disabled your Linux Voice Assistant service in step 2., enable it now:
```bash
sudo systemctl enable --now linux-voice-assistant.service
```
## 16. Reboot your Pi: (see step 4)
## 17. Done! Return to the tutorial to continue the Linux Voice Assistant install.
+302
View File
@@ -0,0 +1,302 @@
# Linux Voice Assistant on RaspberryPi with ReSpeaker 2‑Mic — Installation & Configuration Guide
> Created using ChatGPT 5 with the following prompt:
```html
Using the following github document as a guide
https://github.com/rhasspy/wyoming-satellite/blob/master/docs/tutorial_2mic.md,
take the attached bash history of commands and create a similar document
detailing the installation and configuration of this linux-voice-assistant project.
```
> Modeled after the Wyoming Satellite two‑mic tutorial, adapted from actual shell history.
This guide reproduces a working setup of the **linux-voice-assistant** project with **Wyoming OpenWakeWord** or **MicroWakeWord** on a Raspberry PI Zero 2W and a Respeaker 2‑mic HAT (e.g., seeed-2mic-voicecard). It assumes a fresh system with sudo access and the default "pi" user. Included is the option to use PipeWire or PulseAudio instead of ALSA.
## Prerequisites
- Raspberry Pi OS Lite (64-bit)
- Linux 6.12.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.34-1+rpt1~bookworm
- (2025-06-26) aarch64 GNU/Linux
- Default Python 3.11+ recommended
- A ReSpeaker 2‑mic sound card or compatable
- Network access to your Home Assistant instance
## 1. Install system packages
```bash
sudo apt update
sudo apt upgrade
sudo apt install libportaudio2 build-essential git \
libmpv-dev mpv python3.11-dev avahi-daemon avahi-utils
sudo reboot
```
## 2. Get the code
```bash
git clone https://github.com/imonlinux/linux-voice-assistant.git
```
## 3. Install ReSpeaker drivers
```bash
chmod +x ~/linux-voice-assistant/respeaker2mic/install-respeaker-drivers.sh
sudo ~/linux-voice-assistant/respeaker2mic/install-respeaker-drivers.sh
sudo reboot
```
## 4. Wyoming OpenWakeWord (OWW), skip to step 5. if using MicroWakeWord (MWW)
```bash
git clone https://github.com/rhasspy/wyoming-openwakeword.git
```
```bash
cp ~/linux-voice-assistant/wyoming-openwakeword/requirements.txt ~/wyoming-openwakeword/requirements.txt
cd wyoming-openwakeword/
script/setup
```
## 5. Linux Voice Assistant (LVA)
```bash
cd ~/linux-voice-assistant/
script/setup
```
## 6. Choose your install option "Choose your Adventure!"
Pick **one** of the following install paths. Expand a section to see the exact steps.
> Tip: PipeWire options run services in *user* mode (requires `loginctl enable-linger`); PulseAudio/ALSA options run services in *system* mode.
<details>
<summary><strong>PipeWire + OpenWakeWord (user-mode services)</strong></summary>
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
**Enable linger (required for user services to start after reboot):**
```bash
sudo loginctl enable-linger pi
```
**Install user-mode services:**
```bash
mkdir -p ~/.config/systemd/user
# LVA
cp ~/linux-voice-assistant/service/user-pw-oww-linux-voice-assistant.service.service ~/.config/systemd/user/linux-voice-assistant.service
# OWW
cp ~/linux-voice-assistant/service/user-wyoming-openwakeword.service.service ~/.config/systemd/user/wyoming-openwakeword.service
```
**Enable & start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now wyoming-openwakeword.service
systemctl --user enable --now linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>PipeWire + MicroWakeWord (user-mode services)</strong></summary>
**Prep (PipeWire):** Follow the PipeWire tutorial first: [the tutorial](install_pipewire.md).
**Enable linger:**
```bash
sudo loginctl enable-linger pi
```
**Install user-mode service:**
```bash
mkdir -p ~/.config/systemd/user
cp ~/linux-voice-assistant/service/user-pw-mww-linux-voice-assistant.service.service ~/.config/systemd/user/linux-voice-assistant.service
```
**Enable & start:**
```bash
systemctl --user daemon-reload
systemctl --user enable --now linux-voice-assistant.service
```
**Verify:**
```bash
systemctl --user status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>PulseAudio + OpenWakeWord (system services)</strong></summary>
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
**Install LVA (system) + tuning drop-in:**
```bash
sudo cp ~/linux-voice-assistant/service/pa-oww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
sudo mkdir -p /etc/systemd/system/linux-voice-assistant.service.d
sudo cp ~/linux-voice-assistant/service/10-tuning.conf /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
```
**Install OWW (system):**
```bash
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wyoming-openwakeword linux-voice-assistant
sudo systemctl status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>PulseAudio + MicroWakeWord (system services)</strong></summary>
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
**Install LVA (system) + tuning drop-in:**
```bash
sudo cp ~/linux-voice-assistant/service/pa-mww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
sudo mkdir -p /etc/systemd/system/linux-voice-assistant.service.d
sudo cp ~/linux-voice-assistant/service/10-tuning.conf /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now linux-voice-assistant
sudo systemctl status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>ALSA + OpenWakeWord (system services)</strong></summary>
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
```bash
arecord -l
aplay -l
```
**Install LVA (system):**
```bash
sudo cp ~/linux-voice-assistant/service/alsa-oww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
```
**Install OWW (system):**
```bash
sudo cp ~/linux-voice-assistant/service/wyoming-openwakeword.service /etc/systemd/system/wyoming-openwakeword.service
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wyoming-openwakeword linux-voice-assistant
sudo systemctl status linux-voice-assistant wyoming-openwakeword --no-pager -l
```
</details>
<details>
<summary><strong>ALSA + MicroWakeWord (system services)</strong></summary>
**No extra audio stack needed.** If you’re using a different sound card/driver, confirm device names:
```bash
arecord -l
aplay -l
```
**Install LVA (system):**
```bash
sudo cp ~/linux-voice-assistant/service/alsa-mww-linux-voice-assistant.service /etc/systemd/system/linux-voice-assistant.service
```
**Enable & start:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now linux-voice-assistant
sudo systemctl status linux-voice-assistant --no-pager -l
```
</details>
<details>
<summary><strong>Optional: Advertise LVA to Home Assistant via Avahi</strong></summary>
**Generate & install ESPHome-style mDNS service:**
```bash
chmod +x ~/linux-voice-assistant/script/gen-esphome-avahi.sh
sudo ~/linux-voice-assistant/script/gen-esphome-avahi.sh
sudo systemctl restart avahi-daemon.service
```
</details>
## 7. Connect to Home Assistant
### If HA does not discover the new LVA:
1. In Home Assistant, go to "Settings" -> "Device & services"
2. Click the "Add integration" button
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
4. Enter the IP address of your voice satellite with port 6053
5. Click "Submit"
6. During the registration process, use the wake word that you configured in your linux-voice-assistant.service file. Default is "alexa".
## 8. Verification
- Use "journalctl -u linux-voice-assistant.service -f" to check for errors for ALSA and PluseAudio. Debugging is enabled.
- Use "journalctl --user -u linux-voice-assistant.service -f" to check for errors when using PipeWire. Debugging is enabled.
- Expect logs like `Connected to Home Assistant`
- Look for `[OWW] Detection: name=...` followed by re-arming/cycling
- Ask: *“What time is it?”* and confirm TTS reply
- If you do not get a voice response, check the Voice Assistant that you choose during registration has a voice assigned to it.
### Settings -> Voice assistants -> Assist (the assistant you configured) -> Text-to-speech -> Voice
## 9. Change OWW detection model
After the LVA is registered with HA, you can change the Wake Word model used in the ESPHome Voice Assistant entity.
Project OWW models include:
```text
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)**
```
Additional community provided OWW models available from this repository:
https://github.com/fwartner/home-assistant-wakewords-collection
You just copy the ones you want into the ~/wyoming-openwakeword/wyoming-openwakeword/models directory. If a model is currupted, the wyoming-openwakeword.service will fail to start.
**Word of warning. I have had problems with some of the community provided wake words. YMMV**
## 10. Switching between OWW and MWW. Or ALSA, PW, PA see section 6.
If you intend to switch from PA to ALSA, you must first stop the pusleaudio.service.
```bash
sudo systemctl stop pulseaudio.service
```
```bash
sudo systemctl disable pulseaudio.service
```
+73
View File
@@ -0,0 +1,73 @@
Metadata-Version: 2.4
Name: linux-voice-assistant
Version: 1.0.0
Summary: Linux voice assistant for Home Assistant using the ESPHome protocol
Author-email: The Home Assistant Authors <hello@home-assistant.io>
License: Apache-2.0
Project-URL: Source Code, http://github.com/OHF-Voice/linux-voice-assistant
Keywords: home,assistant,voice,esphome,linux
Platform: any
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Text Processing :: Linguistic
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9.0
Description-Content-Type: text/markdown
License-File: LICENSE.md
Requires-Dist: aioesphomeapi==37.2.1
Requires-Dist: sounddevice<1
Requires-Dist: numpy<3,>=2
Requires-Dist: pymicro-features==1.0.0
Provides-Extra: dev
Requires-Dist: black==24.8.0; extra == "dev"
Requires-Dist: flake8==7.2.0; extra == "dev"
Requires-Dist: mypy==1.14.0; extra == "dev"
Requires-Dist: pylint==3.2.7; extra == "dev"
Requires-Dist: pytest==8.3.5; extra == "dev"
Dynamic: license-file
# Linux Voice Assistant
Experimental Linux voice assistant for [Home Assistant][homeassistant] that uses the [ESPHome][esphome] protocol.
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.13 and Python 3.11.
Supports announcments, start/continue conversation, and timers.
## Installation
Install system dependencies (`apt-get`):
* `libportaudio2` (for `sounddevice`)
* `build-essential` (for `pymicro-features`)
* `libmpv-dev` (for `python-mpv`)
Clone and install project:
``` sh
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
cd linux-voice-assistant
script/setup
```
## Running
Use `script/run` or `python3 -m linux_voice_assistant`
See `--help` for more options.
## Connecting to Home Assistant
1. In Home Assistant, go to "Settings" -> "Device & services"
2. Click the "Add integration" button
3. Choose "ESPHome" and then "Set up another instance of ESPHome"
4. Enter the IP address of your voice satellite with port 6053
5. Click "Submit"
<!-- Links -->
[homeassistant]: https://www.home-assistant.io/
[esphome]: https://esphome.io/
@@ -0,0 +1,19 @@
LICENSE.md
README.md
pyproject.toml
setup.cfg
linux_voice_assistant/__init__.py
linux_voice_assistant/__main__.py
linux_voice_assistant/api_server.py
linux_voice_assistant/entity.py
linux_voice_assistant/event_bus.py
linux_voice_assistant/event_led.py
linux_voice_assistant/microwakeword.py
linux_voice_assistant/mpv_player.py
linux_voice_assistant/util.py
linux_voice_assistant.egg-info/PKG-INFO
linux_voice_assistant.egg-info/SOURCES.txt
linux_voice_assistant.egg-info/dependency_links.txt
linux_voice_assistant.egg-info/requires.txt
linux_voice_assistant.egg-info/top_level.txt
linux_voice_assistant.egg-info/zip-safe
@@ -0,0 +1 @@
@@ -0,0 +1,11 @@
aioesphomeapi==37.2.1
sounddevice<1
numpy<3,>=2
pymicro-features==1.0.0
[dev]
black==24.8.0
flake8==7.2.0
mypy==1.14.0
pylint==3.2.7
pytest==8.3.5
@@ -0,0 +1 @@
linux_voice_assistant
+1
View File
@@ -0,0 +1 @@
+173 -10
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import os
import asyncio
import json
import logging
@@ -8,10 +9,26 @@ import threading
import time
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from queue import Queue
from typing import Dict, List, Optional
# Models that should NOT appear in UI dropdowns
EXCLUDED_OWW_MODELS = {"embedding_model", "melspectrogram"}
import re
def _prettify_model_name(name: str) -> str:
stem = Path(name).stem
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
return " ".join(parts)
# pylint: disable=no-name-in-module
import sounddevice as sd
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
@@ -42,9 +59,24 @@ from google.protobuf import message
from .api_server import APIServer
from .entity import ESPHomeEntity, MediaPlayerEntity
from .microwakeword import MicroWakeWord
from .openwakeword_client import WyomingWakeClient
from .mpv_player import MpvMediaPlayer
from .util import call_all, get_mac, is_arm
from .event_bus import EventBus
from .event_led import LedEvent
from pathlib import Path
# Hide helper models that are not detectors
_OWW_DETECT_MODEL_DENYLIST = {"embedding_model", "melspectrogram"}
def _oww_filter_models(models: list[str]) -> list[str]:
"""Normalize names (strip .tflite/paths), drop helper models, sort & de-dupe."""
stems = {Path(m).stem for m in models}
return sorted(m for m in stems if m not in _OWW_DETECT_MODEL_DENYLIST)
_LOGGER = logging.getLogger(__name__)
_MODULE_DIR = Path(__file__).parent
_REPO_DIR = _MODULE_DIR.parent
@@ -78,8 +110,13 @@ class ServerState:
tts_player: MpvMediaPlayer
wakeup_sound: str
timer_finished_sound: str
loop: asyncio.AbstractEventLoop
event_bus: EventBus
media_player_entity: Optional[MediaPlayerEntity] = None
satellite: "Optional[VoiceSatelliteProtocol]" = None
current_remote_wake: Optional[str] = None
wyoming_wake: "Optional[WyomingWakeClient]" = None
use_wyoming_wake: bool = False
# -----------------------------------------------------------------------------
@@ -110,11 +147,16 @@ class VoiceSatelliteProtocol(APIServer):
self._continue_conversation = False
self._timer_finished = False
self.state.event_bus.publish('ready', {})
_LOGGER.info('System is ready!')
def handle_voice_event(
self, event_type: VoiceAssistantEventType, data: Dict[str, str]
) -> None:
_LOGGER.debug("Voice event: type=%s, data=%s", event_type.name, data)
self.state.event_bus.publish(f'voice_{event_type.name}', data)
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
self._tts_url = data.get("url")
self._tts_played = False
@@ -157,6 +199,7 @@ class VoiceSatelliteProtocol(APIServer):
self._play_timer_finished()
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
_LOGGER.debug(f'message {msg.__name__}')
if isinstance(msg, VoiceAssistantEventResponse):
# Pipeline event
data: Dict[str, str] = {}
@@ -197,14 +240,11 @@ class VoiceSatelliteProtocol(APIServer):
| VoiceAssistantFeature.TIMERS
),
)
elif isinstance(
msg,
(
elif isinstance(msg, (
ListEntitiesRequest,
SubscribeHomeAssistantStatesRequest,
MediaPlayerCommandRequest,
),
):
),):
for entity in self.state.entities:
yield from entity.handle_message(msg)
@@ -220,7 +260,7 @@ class VoiceSatelliteProtocol(APIServer):
)
for ww in self.state.available_wake_words.values()
],
active_wake_words=[self.state.wake_word.id],
active_wake_words=[self.state.current_remote_wake] if self.state.use_wyoming_wake else [self.state.wake_word.id],
max_active_wake_words=1,
)
_LOGGER.info("Connected to Home Assistant")
@@ -231,6 +271,15 @@ class VoiceSatelliteProtocol(APIServer):
# Already active
break
if self.state.use_wyoming_wake and self.state.wyoming_wake:
try:
self.state.wyoming_wake.set_detect([wake_word_id])
_LOGGER.info("Switched Wyoming wake model to: %s", wake_word_id)
self.state.current_remote_wake = wake_word_id
self.state.wake_word.is_active = False
except Exception:
_LOGGER.exception("Failed to switch Wyoming wake model")
break
model_info = self.state.available_wake_words.get(wake_word_id)
if not model_info:
continue
@@ -245,13 +294,13 @@ class VoiceSatelliteProtocol(APIServer):
break
def handle_audio(self, audio_chunk: bytes) -> None:
if not self._is_streaming_audio:
return
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
def wakeup(self) -> None:
# Why are we stopping the timer? Wouldn't it be better to delay it?
if self._timer_finished:
# Stop timer instead
self._timer_finished = False
@@ -259,11 +308,19 @@ class VoiceSatelliteProtocol(APIServer):
_LOGGER.debug("Stopping timer finished sound")
return
wake_word_phrase = self.state.wake_word.wake_word
wake_word_phrase = (self.state.current_remote_wake or self.state.wake_word.wake_word) if self.state.use_wyoming_wake else self.state.wake_word.wake_word
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
try:
self.state.wyoming_wake.suppress(2500)
except Exception:
pass
self.send_messages(
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
)
self.state.event_bus.publish('voice_wakeword', {'wake_word_phrase': wake_word_phrase})
self.duck()
self._is_streaming_audio = True
self.state.tts_player.play(self.state.wakeup_sound)
@@ -286,6 +343,8 @@ class VoiceSatelliteProtocol(APIServer):
self._tts_played = True
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
self.state.event_bus.publish('voice_play_tts', {})
self.state.stop_word.is_active = True
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
@@ -301,6 +360,9 @@ class VoiceSatelliteProtocol(APIServer):
self.state.stop_word.is_active = False
self.send_messages([VoiceAssistantAnnounceFinished()])
# Actual time the TTS stops speaking
self.state.event_bus.publish('voice__tts_finished', {})
if self._continue_conversation:
self.send_messages([VoiceAssistantRequest(start=True)])
self._is_streaming_audio = True
@@ -341,6 +403,10 @@ def process_audio(state: ServerState):
try:
state.satellite.handle_audio(audio_chunk)
if state.use_wyoming_wake:
if state.wyoming_wake:
state.wyoming_wake.send_audio_chunk(audio_chunk)
else:
if state.wake_word.is_active and state.wake_word.process_streaming(
audio_chunk
):
@@ -363,6 +429,9 @@ def process_audio(state: ServerState):
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--name", required=True)
parser.add_argument("--wake-uri", help="Wyoming wake server URI (e.g., tcp://127.0.0.1:10400)")
parser.add_argument("--wake-word-name", help="Wake word name on the Wyoming server (e.g., hal)")
parser.add_argument("--wake-models-dir", help="Fallback: directory containing openWakeWord *.tflite models to list if the server does not advertise them")
parser.add_argument(
"--audio-input-device",
default="default",
@@ -401,6 +470,15 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
# Optional fallback for discovering remote models from filesystem
models_dir = args.wake_models_dir or os.environ.get("WYOMING_MODELS_DIR")
use_wyoming = bool(args.wake_uri)
wyoming_client = None
if use_wyoming:
_LOGGER.info("Using Wyoming openWakeWord at %s (name=%s)", args.wake_uri, args.wake_word_name)
wyoming_client = WyomingWakeClient(args.wake_uri, args.wake_word_name or "")
# Load available wake words
wake_word_dir = Path(args.wake_word_dir)
available_wake_words: Dict[str, AvailableWakeWord] = {}
@@ -421,6 +499,28 @@ async def main() -> None:
_LOGGER.debug("Available wake words: %s", list(sorted(available_wake_words.keys())))
if use_wyoming and wyoming_client:
try:
remote_models = wyoming_client.list_models() or []
_LOGGER.debug("Queried Wyoming models: %s", [m.get("name") if isinstance(m, dict) else m for m in remote_models])
except Exception:
remote_models = []
_LOGGER.exception("Failed to list models from Wyoming server")
if remote_models:
available_wake_words = {}
for m in remote_models:
name = (m.get("name") or "").strip()
langs = m.get("languages") or m.get("trained_languages") or []
if not name:
continue
available_wake_words[name] = AvailableWakeWord(
id=name,
wake_word=(m.get('label') or _prettify_model_name(name)),
trained_languages=langs,
config_path=Path(f"wyoming:{name}"),
)
_LOGGER.debug("Remote OpenWakeWord models: %s", list(sorted(available_wake_words.keys())))
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
_LOGGER.debug("libtensorflowlite_c path: %s", libtensorflowlite_c_path)
@@ -433,6 +533,8 @@ async def main() -> None:
_LOGGER.debug("Loading stop model: %s", stop_config_path)
stop_model = MicroWakeWord.from_config(stop_config_path, libtensorflowlite_c_path)
loop = asyncio.get_running_loop()
state = ServerState(
name=args.name,
mac_address=get_mac(),
@@ -441,12 +543,74 @@ async def main() -> None:
available_wake_words=available_wake_words,
wake_word=wake_model,
stop_word=stop_model,
event_bus=EventBus(),
loop=loop,
music_player=MpvMediaPlayer(device=args.audio_output_device),
tts_player=MpvMediaPlayer(device=args.audio_output_device),
wakeup_sound=args.wakeup_sound,
timer_finished_sound=args.timer_finished_sound,
wyoming_wake=wyoming_client,
use_wyoming_wake=use_wyoming,
)
LedEvent(state)
# Connect to Wyoming wake server if enabled
if state.use_wyoming_wake and state.wyoming_wake:
def _on_detect(_name, _ts):
if state.satellite is not None:
state.loop.call_soon_threadsafe(lambda: state.satellite.wakeup())
state.wyoming_wake.connect(_on_detect)
# Disable local wake model to avoid double triggers
state.wake_word.is_active = False
# Refresh remote models after connect
try:
cached = state.wyoming_wake.get_models(timeout=1.5) if state.wyoming_wake else []
if not cached and models_dir:
try:
from pathlib import Path as _P
cached = [{"name": p.stem} for p in _P(models_dir).glob("*.tflite")]
cached = [
m for m in cached
if (m.get('name') or '').strip() not in EXCLUDED_OWW_MODELS
]
_LOGGER.info("Using filesystem fallback for OWW models (post-connect): %s", [m["name"] for m in cached])
except Exception:
_LOGGER.exception("Failed to scan wake-models-dir (post-connect)")
if cached:
available = {}
for m in cached:
name = (m.get('name') or '').strip()
if not name:
continue
available[name] = AvailableWakeWord(
id=name,
wake_word=(m.get('label') or _prettify_model_name(name)),
trained_languages=[],
config_path=Path(f"wyoming:{name}")
)
state.available_wake_words = available
_LOGGER.debug("Using remote models from cached info: %s", list(sorted(state.available_wake_words.keys())))
except Exception:
_LOGGER.exception("Failed to refresh remote models from connection info")
# Initialize remote detection target if provided
try:
if args.wake_word_name:
state.wyoming_wake.set_detect([args.wake_word_name])
state.current_remote_wake = args.wake_word_name
else:
try:
first = next(iter(state.available_wake_words.keys()))
except StopIteration:
first = None
if first:
state.wyoming_wake.set_detect([first])
state.current_remote_wake = first
else:
_LOGGER.warning("Wyoming enabled but no remote models available; not sending detect yet")
except Exception:
_LOGGER.exception("Failed to initialize remote detect")
process_audio_thread = threading.Thread(
target=process_audio, args=(state,), daemon=True
)
@@ -455,7 +619,6 @@ async def main() -> None:
def sd_callback(indata, _frames, _time, _status):
state.audio_queue.put_nowait(bytes(indata))
loop = asyncio.get_running_loop()
server = await loop.create_server(
lambda: VoiceSatelliteProtocol(state), host=args.host, port=args.port
)
+67
View File
@@ -0,0 +1,67 @@
import logging
from typing import Any, Callable, Dict, List, Optional
_LOGGER = logging.getLogger(__name__)
class EventBus:
"""A simple synchronous publish/subscribe event bus."""
def __init__(self):
# A dictionary to hold listeners for specific string topics
self.topics: Dict[str, List[Callable[[Any], None]]] = {}
def subscribe(self, topic: str, listener: Callable[[Any], None]) -> None:
"""
Subscribes a listener to a topic.
"""
# _LOGGER.debug(f'EventBus subscribe {topic}')
if topic not in self.topics:
self.topics[topic] = []
self.topics[topic].append(listener)
def publish(self, topic: str, data: [dict, None]) -> None:
"""
Publishes an event to all subscribed listeners.
"""
# _LOGGER.debug(f'EventBus publish {topic}')
data['__topic'] = topic
listeners = self.topics.get(topic, [])
for listener in listeners:
listener(data)
# Client helpers for subscriptions
# The decorator to mark methods for subscription.
def subscribe(func: Callable) -> Callable:
"""Decorator to mark a method for event bus subscription."""
func._event_bus_subscribe = True
return func
class EventHandler:
"""
A base class for components that subscribe to events.
Subclasses should define event handlers as methods decorated with `@subscribe`.
The method name will automatically be used as the event topic.
"""
def __init__(self, state: Any):
self.state = state
self._subscribe_all_methods()
_LOGGER.debug(f"EventHandler {self.__class__.__name__} has subscribed to all decorated methods.")
def _subscribe_all_methods(self):
"""Finds and subscribes all methods decorated with @subscribe."""
for method_name in dir(self):
method = getattr(self, method_name)
if hasattr(method, '_event_bus_subscribe'):
# The topic is the name of the method itself.
self.state.event_bus.subscribe(method_name, method)
_LOGGER.debug(f"Subscribed method '{method_name}' to topic '{method_name}'")
+262
View File
@@ -0,0 +1,262 @@
import logging
from typing import Any, Callable
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
"""Controls the LEDs on the ReSpeaker 2mic HAT."""
from math import ceil
from typing import Tuple
import time
import asyncio
import gpiozero
import spidev
NUM_LEDS = 3
LEDS_GPIO = 12
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
# -----------------------------------------------------------------------------
class APA102:
"""
Driver for APA102 LEDS (aka "DotStar").
(c) Martin Erzberger 2016-2017
"""
# Constants
MAX_BRIGHTNESS = 0b11111 # Safeguard: Set to a value appropriate for your setup
LED_START = 0b11100000 # Three "1" bits, followed by 5 brightness bits
def __init__(
self,
num_led,
global_brightness,
loop: asyncio.AbstractEventLoop,
order="rgb",
bus=0,
device=1,
max_speed_hz=8000000,
):
self.num_led = num_led # The number of LEDs in the Strip
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
# Limit the brightness to the maximum if it's set higher
if global_brightness > self.MAX_BRIGHTNESS:
self.global_brightness = self.MAX_BRIGHTNESS
else:
self.global_brightness = global_brightness
print("LED brightness:", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task = None
self.loop = loop
def clock_start_frame(self):
"""Sends a start frame to the LED strip.
This method clocks out a start frame, telling the receiving LED
that it must update its own color now.
"""
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
"""Sends an end frame to the LED strip.
As explained above, dummy data must be sent after the last real colour
information so that all of the data can reach its destination down the line.
The delay is not as bad as with the human example above.
It is only 1/2 bit per LED. This is because the SPI clock line
needs to be inverted.
Say a bit is ready on the SPI data line. The sender communicates
this by toggling the clock line. The bit is read by the LED
and immediately forwarded to the output data line. When the clock goes
down again on the input side, the LED will toggle the clock up
on the output to tell the next LED that the bit is ready.
After one LED the clock is inverted, and after two LEDs it is in sync
again, but one cycle behind. Therefore, for every two LEDs, one bit
of delay gets accumulated. For 300 LEDs, 150 additional bits must be fed to
the input of LED one so that the data can reach the last LED.
Ultimately, we need to send additional numLEDs/2 arbitrary data bits,
in order to trigger numLEDs/2 additional clock changes. This driver
sends zeroes, which has the benefit of getting LED one partially or
fully ready for the next update to the strip. An optimized version
of the driver could omit the "clockStartFrame" method if enough zeroes have
been sent as part of "clockEndFrame".
"""
self.spi.xfer2([0xFF] * 4)
# Round up num_led/2 bits (or num_led/16 bytes)
# for _ in range((self.num_led + 15) // 16):
# self.spi.xfer2([0x00])
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer. Colors are passed individually.
If brightness is not set the global brightness setting is used.
"""
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
# Calculate pixel brightness as a percentage of the
# defined global_brightness. Round up to nearest integer
# as we expect some brightness unless set to 0
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
# LED startframe is three "1" bits, followed by 5 brightness bits
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
"""Sets the color of one pixel in the LED stripe.
The changed pixel is not shown yet on the Stripe, it is only
written to the pixel buffer.
Colors are passed combined (3 bytes concatenated)
If brightness is not set the global brightness setting is used.
"""
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent or self.global_brightness,
)
def rotate(self, positions=1):
"""Rotate the LEDs by the specified number of positions.
Treating the internal LED array as a circular buffer, rotate it by
the specified number of positions. The number could be negative,
which means rotating in the opposite direction.
"""
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
"""Sends the content of the pixel buffer to the strip.
Todo: More than 1024 LEDs requires more than one xfer operation.
"""
self.clock_start_frame()
# xfer2 kills the list, unfortunately. So it must be copied first
# SPI takes up to 4096 Integers. So we are fine for up to 1024 LEDs.
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
"""Release the SPI device; Call this method at the end"""
self.spi.close() # Close SPI port
def run_action(self, action_mentod_name: str, *args: Any) -> None:
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(getattr(self, action_mentod_name)(*args), self.loop)
async def color(self, rgb: Tuple[int, int, int], brightness = None) -> None:
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def blink(self, color, count=10000):
for _ in range(count):
await self.color(color)
await asyncio.sleep(0.3)
await self.color(_OFF)
await asyncio.sleep(0.3)
async def pulse(self, color: Tuple[int, int, int], speed: float = 0.009):
"""Asynchronously pulses the LEDs from off to full brightness and back."""
# Fade in
while(1):
for brightness in range(1, self.global_brightness+1):
await self.color(color, brightness)
await asyncio.sleep(speed)
# Fade out
for brightness in range(self.global_brightness+1, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
@subscribe
def ready(self, data: dict):
_LOGGER.debug('ready LED green blink')
self.leds.run_action("blink", _GREEN, 3)
@subscribe
def voice_wakeword(self, data: dict):
self.leds.run_action("pulse", _BLUE)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
self.leds.run_action("pulse", _YELLOW)
@subscribe
def voice_play_tts(self, data: dict):
self.leds.run_action("pulse", _GREEN)
# This event fires long before the TTS is done speaking
# @subscribe
# def voice_VOICE_ASSISTANT_RUN_END(self, data: dict):
# self.leds.run_action("color", _OFF, 0)
@subscribe
def voice__tts_finished(self, data: dict):
self.leds.run_action("color", _OFF, 0)
+128
View File
@@ -0,0 +1,128 @@
# MODIFIED event_led.py
import logging
import asyncio
from typing import Tuple, Any
# --- MODIFICATION: Import new libraries and remove unused ones ---
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- MODIFICATION: Make NUM_LEDS a configurable variable at the top ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 2
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# Define some color constants
_OFF = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
# --- MODIFICATION: The old spidev-based APA102 class has been removed entirely ---
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task = None
# --- MODIFICATION: Initialize LEDs using Adafruit DotStar and GPIO pins ---
# The ReSpeaker 2-Mic HAT Grove connector uses GPIO13 (Clock) and GPIO12 (Data)
# If your wiring is different, change board.D13 and board.D12 accordingly.
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False # We will call show() manually
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
_LOGGER.error("Please ensure you have run 'pip install adafruit-circuitpython-dotstar' and that SPI is enabled if prompted.")
self.leds = None # Prevent crashes if initialization fails
def run_action(self, action_method_name: str, *args: Any) -> None:
if not self.leds:
_LOGGER.warning("LEDs not initialized, skipping action.")
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(getattr(self, action_method_name)(*args), self.loop)
# --- MODIFICATION: Rewritten 'color' method to work with the new library ---
async def color(self, rgb: Tuple[int, int, int], brightness_override: float = None) -> None:
if brightness_override is not None:
self.leds.brightness = brightness_override
else:
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
# --- MODIFICATION: Rewritten 'blink' method to be simpler ---
async def blink(self, rgb: Tuple[int, int, int], count=10000):
try:
for _ in range(count):
self.leds.fill(rgb)
self.leds.show()
await asyncio.sleep(0.3)
self.leds.fill(_OFF)
self.leds.show()
await asyncio.sleep(0.3)
except asyncio.CancelledError:
pass # Task was cancelled, which is normal
# --- MODIFICATION: Rewritten 'pulse' method for the new library ---
async def pulse(self, color: Tuple[int, int, int], speed: float = 0.015):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = i / 100.0
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = i / 100.0
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
except asyncio.CancelledError:
self.leds.brightness = GLOBAL_BRIGHTNESS # Restore brightness on exit
# --- Event subscriptions remain the same ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug('ready LED green blink')
# Blink 3 times and then turn off
self.run_action("blink", _GREEN, 3)
@subscribe
def voice_wakeword(self, data: dict):
self.run_action("pulse", _BLUE)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
self.run_action("pulse", _YELLOW)
@subscribe
def voice_play_tts(self, data: dict):
self.run_action("pulse", _GREEN)
@subscribe
def voice__tts_finished(self, data: dict):
self.run_action("color", _OFF)
@@ -0,0 +1,158 @@
# MODIFIED event_led_hal.py (v2 - with cancellation fix)
import logging
import asyncio
from typing import Any, Tuple, Optional
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- Configuration ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 1
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# --- Color and Timing Constants ---
_OFF = (0, 0, 0)
_RED = (255, 0, 0)
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task: Optional[asyncio.Future] = None
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
self.leds = None
self._base_color = _RED
self._listen_idle_watchdog: Optional[asyncio.Task] = None
self.run_action("color", _OFF)
def run_action(self, action_method_name: str, *args: Any):
if not self.leds:
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
# --- LED Action Methods ---
async def color(self, rgb: Tuple[int, int, int]):
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
# --- MODIFICATION: This function is now more robust to cancellation ---
async def pulse(self, color: Tuple[int, int, int], speed=0.02):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
finally:
# This 'finally' block ensures brightness is restored even
# when the task is cancelled.
self.leds.brightness = GLOBAL_BRIGHTNESS
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times, on_time=0.05, off_time=0.05)
await self.pulse(pulse_color, pulse_speed)
# --- Watchdog Logic ---
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# --- Event Subscriptions ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.run_action("color", _OFF)
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast RED flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.run_action("flash_then_pulse", _RED, 2, _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure RED pulsing (listening)")
self._cancel_watchdog()
self.run_action("pulse", _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid RED")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@@ -0,0 +1,156 @@
# event_led_marvin.py
# Based on the corrected HAL file, with red changed to green.
import logging
import asyncio
from typing import Any, Tuple, Optional
import board
import adafruit_dotstar
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
# --- Configuration ---
# Set this to the number of APA102 LEDs you have connected to the Grove port.
NUM_LEDS = 2
GLOBAL_BRIGHTNESS = 1.0 # Brightness as a value from 0.0 to 1.0
# --- Color and Timing Constants ---
_OFF = (0, 0, 0)
_GREEN = (0, 255, 0) # Changed from RED to GREEN
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.loop = self.state.loop
self.current_task: Optional[asyncio.Future] = None
try:
self.leds = adafruit_dotstar.DotStar(
clock=board.D13,
data=board.D12,
n=NUM_LEDS,
brightness=GLOBAL_BRIGHTNESS,
auto_write=False
)
_LOGGER.info(f"Initialized {NUM_LEDS} APA102 LEDs on GPIO12/13.")
except Exception as e:
_LOGGER.error(f"Failed to initialize DotStar LEDs: {e}")
self.leds = None
self._base_color = _GREEN # Changed from _RED
self._listen_idle_watchdog: Optional[asyncio.Task] = None
self.run_action("color", _OFF)
def run_action(self, action_method_name: str, *args: Any):
if not self.leds:
return
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
# --- LED Action Methods ---
async def color(self, rgb: Tuple[int, int, int]):
self.leds.brightness = GLOBAL_BRIGHTNESS
self.leds.fill(rgb)
self.leds.show()
async def pulse(self, color: Tuple[int, int, int], speed=0.02):
try:
while True:
# Fade in
for i in range(101):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
# Fade out
for i in range(100, -1, -1):
self.leds.brightness = (i / 100.0) * GLOBAL_BRIGHTNESS
self.leds.fill(color)
self.leds.show()
await asyncio.sleep(speed)
finally:
self.leds.brightness = GLOBAL_BRIGHTNESS
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times, on_time=0.05, off_time=0.05)
await self.pulse(pulse_color, pulse_speed)
# --- Watchdog Logic ---
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# --- Event Subscriptions ---
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.run_action("color", _OFF)
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast GREEN flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.run_action("flash_then_pulse", _GREEN, 2, _GREEN, 0.02) # Changed from _RED
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure GREEN pulsing (listening)")
self._cancel_watchdog()
self.run_action("pulse", _GREEN, 0.02) # Changed from _RED
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid GREEN")
self._cancel_watchdog()
self.run_action("color", self._base_color)
+188
View File
@@ -0,0 +1,188 @@
import logging
from typing import Any, Tuple, Optional
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
from math import ceil
import asyncio
import spidev
NUM_LEDS = 3
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_RED = (255, 0, 0)
# Keep listening pulse alive for up to 15s after wakeword if user doesn't speak
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class APA102:
MAX_BRIGHTNESS = 0b11111
LED_START = 0b11100000
def __init__(
self,
num_led: int,
global_brightness: int,
loop: asyncio.AbstractEventLoop,
order: str = "rgb",
bus: int = 0,
device: int = 1,
max_speed_hz: int = 8000000,
):
self.num_led = num_led
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
self.global_brightness = min(global_brightness, self.MAX_BRIGHTNESS)
_LOGGER.debug("LED brightness: %s", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led
self.spi = spidev.SpiDev()
self.spi.open(bus, device)
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task: Optional[asyncio.Future] = None
self.loop = loop
def clock_start_frame(self):
self.spi.xfer2([0] * 4)
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0 or led_num >= self.num_led:
return
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close()
def run_action(self, action_method_name: str, *args: Any):
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
async def color(self, rgb, brightness=None):
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def pulse(self, color, speed=0.02):
while True:
for brightness in range(1, self.MAX_BRIGHTNESS + 1):
await self.color(color, brightness)
await asyncio.sleep(speed)
for brightness in range(self.MAX_BRIGHTNESS, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
await self.flash(flash_color, times)
await self.pulse(pulse_color, pulse_speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
self._base_color = _RED
self.leds.run_action("color", _OFF)
self._listen_idle_watchdog: Optional[asyncio.Task] = None
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.leds.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# Connectivity ------------------------------------------------------------------
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.leds.run_action("color", _OFF)
# Wakeword ----------------------------------------------------------------------
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast RED flash x2, then enter listening (pulse)")
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
self.leds.run_action("flash_then_pulse", _RED, 2, _RED, 0.02)
# Listening (VAD) ---------------------------------------------------------------
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure RED pulsing (listening)")
self._cancel_watchdog()
self.leds.run_action("pulse", _RED, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
# TTS (speaking) ----------------------------------------------------------------
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid RED")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
+198
View File
@@ -0,0 +1,198 @@
import logging
from typing import Any, Tuple, Optional
from .event_bus import EventHandler, subscribe
_LOGGER = logging.getLogger(__name__)
from math import ceil
import asyncio
import spidev
NUM_LEDS = 2
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
_OFF = (0, 0, 0)
_GREEN = (0, 255, 0)
# How long to keep "listening" after wakeword if user never starts speaking
LISTEN_IDLE_TIMEOUT_SEC = 15.0
class APA102:
MAX_BRIGHTNESS = 0b11111
LED_START = 0b11100000
def __init__(
self,
num_led: int,
global_brightness: int,
loop: asyncio.AbstractEventLoop,
order: str = "rgb",
bus: int = 0,
device: int = 1,
max_speed_hz: int = 8000000,
):
self.num_led = num_led
order = order.lower()
self.rgb = RGB_MAP.get(order, RGB_MAP["rgb"])
self.global_brightness = min(global_brightness, self.MAX_BRIGHTNESS)
_LOGGER.debug("LED brightness: %s", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led
self.spi = spidev.SpiDev()
self.spi.open(bus, device)
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
self.current_task: Optional[asyncio.Future] = None
self.loop = loop
def clock_start_frame(self):
self.spi.xfer2([0] * 4)
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0 or led_num >= self.num_led:
return
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close()
def run_action(self, action_method_name: str, *args: Any):
# Cancel any currently running animation task (pulse/blink/etc.)
if self.current_task and not self.current_task.done():
self.current_task.cancel()
self.current_task = asyncio.run_coroutine_threadsafe(
getattr(self, action_method_name)(*args), self.loop
)
async def color(self, rgb, brightness=None):
for i in range(NUM_LEDS):
self.set_pixel(i, rgb[0], rgb[1], rgb[2], brightness or self.global_brightness)
self.show()
async def pulse(self, color, speed=0.02):
"""Pulse continuously until cancelled."""
while True:
for brightness in range(1, self.MAX_BRIGHTNESS + 1):
await self.color(color, brightness)
await asyncio.sleep(speed)
for brightness in range(self.MAX_BRIGHTNESS, 0, -1):
await self.color(color, brightness)
await asyncio.sleep(speed)
async def flash(self, flash_color, times, on_time=0.1, off_time=0.1):
"""Flash color on/off N times, then leave LEDs OFF (caller decides next state)."""
for _ in range(times):
await self.color(flash_color)
await asyncio.sleep(on_time)
await self.color(_OFF)
await asyncio.sleep(off_time)
async def flash_then_pulse(self, flash_color, times, pulse_color, pulse_speed=0.02):
"""Flash N times, then enter continuous pulse."""
await self.flash(flash_color, times)
await self.pulse(pulse_color, pulse_speed)
class LedEvent(EventHandler):
def __init__(self, state):
super().__init__(state)
self.leds = APA102(num_led=3, global_brightness=31, loop=self.state.loop)
self._base_color = _GREEN
# Keep LEDs off until connected
self.leds.run_action("color", _OFF)
# Watchdog for the "no speech after wakeword" case
self._listen_idle_watchdog: Optional[asyncio.Task] = None
def _cancel_watchdog(self):
if self._listen_idle_watchdog and not self._listen_idle_watchdog.done():
self._listen_idle_watchdog.cancel()
self._listen_idle_watchdog = None
async def _listen_watchdog(self, timeout: float):
try:
await asyncio.sleep(timeout)
_LOGGER.debug("Listen idle watchdog timeout -> return to base color")
self.leds.run_action("color", self._base_color)
except asyncio.CancelledError:
pass
# Connectivity ------------------------------------------------------------------
@subscribe
def ready(self, data: dict):
_LOGGER.debug("ready -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def disconnected(self, data: dict):
_LOGGER.debug("disconnected -> LEDs OFF")
self._cancel_watchdog()
self.leds.run_action("color", _OFF)
# Wakeword ----------------------------------------------------------------------
@subscribe
def voice_wakeword(self, data: dict):
_LOGGER.debug("wakeword -> fast GREEN flash x2, then enter listening (pulse)")
# Start a watchdog: if user never speaks, stop pulsing after timeout
self._cancel_watchdog()
self._listen_idle_watchdog = self.state.loop.create_task(
self._listen_watchdog(LISTEN_IDLE_TIMEOUT_SEC)
)
# Flash twice quickly, then pulse
self.leds.run_action("flash_then_pulse", _GREEN, 2, _GREEN, 0.02)
# Listening (VAD) ---------------------------------------------------------------
@subscribe
def voice_VOICE_ASSISTANT_STT_START(self, data: dict):
_LOGGER.debug("VAD_START -> ensure GREEN pulsing (listening)")
# User started speaking: cancel the idle watchdog and ensure pulse
self._cancel_watchdog()
self.leds.run_action("pulse", _GREEN, 0.02)
@subscribe
def voice_VOICE_ASSISTANT_STT_VAD_END(self, data: dict):
_LOGGER.debug("VAD_END -> stop pulsing -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
# TTS (speaking) ----------------------------------------------------------------
@subscribe
def voice_play_tts(self, data: dict):
_LOGGER.debug("TTS -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
@subscribe
def voice__tts_finished(self, data: dict):
_LOGGER.debug("TTS finished -> solid GREEN")
self._cancel_watchdog()
self.leds.run_action("color", self._base_color)
+341 -59
View File
@@ -1,94 +1,376 @@
"""Media player using mpv in a subprocess."""
# linux_voice_assistant/mpv_player.py
"""
Media player using mpv in a subprocess.
- Supports ALSA, PulseAudio, and PipeWire (native)
- Env override for backend: LVA_AO=pipewire|pulse|alsa (aliases: native/pw -> pipewire, pulseaudio -> pulse)
- Duck/unduck
- Robust URL handling (str | list/tuple | bytes)
- Watchdog is **disabled by default** (set watchdog_sec > 0 to enable)
- End-of-playback detection uses BOTH `eof-reached` and `idle-active`
- mpv log level configurable via env var LVA_MPV_MSG_LEVEL (e.g. "all=warn", "all=info")
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from threading import Lock
from typing import List, Optional, Union
import os
from threading import Lock, Timer
from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
from mpv import MPV
_LOGGER = logging.getLogger(__name__)
# ---- constants & helpers -----------------------------------------------------
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None) -> None:
self.player = MPV()
_DEFAULT_MSG_LEVEL = "all=warn"
# Map friendly env tokens to canonical mpv `ao` names
_AO_ENV_ALIASES = {
"native": "pipewire",
"pw": "pipewire",
"pulseaudio": "pulse",
}
_AO_ALLOWED = {"pipewire", "pulse", "alsa"}
def _normalize_env_ao(value: Optional[str]) -> Optional[str]:
"""Normalize LVA_AO environment override into one of _AO_ALLOWED or None."""
if not value:
return None
v = value.strip().lower()
v = _AO_ENV_ALIASES.get(v, v)
return v if v in _AO_ALLOWED else None
def _set_player_opt(player: MPV, key: str, value) -> None:
"""Set an mpv option with logging on failure."""
try:
player[key] = value
except Exception:
_LOGGER.exception("Failed to set mpv option %r=%r", key, value, exc_info=True)
def _select_backend(
player: MPV,
device: Optional[str],
env_ao: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""
Decide and apply (ao, audio-device) after probing candidates in order.
Returns:
(ao, audio_device) — the values successfully applied (or (None, None)).
"""
ao: Optional[str] = None
norm: Optional[str] = None
def try_candidates(candidates: Iterable[Tuple[str, str]]) -> bool:
nonlocal ao, norm
for cand_ao, cand_norm in candidates:
try:
player["ao"] = cand_ao # probe
ao, norm = cand_ao, cand_norm
return True
except Exception:
# Keep trying next backend
continue
return False
if device:
self.player["audio-device"] = device
d = device.strip()
if d.startswith("alsa/"):
ao, norm = "alsa", d
elif d.startswith("pulse/"):
ao, norm = "pulse", d
elif d.startswith(("pipewire/", "pw/")):
ao, norm = "pipewire", d.replace("pw/", "pipewire/", 1)
elif d == "default":
# Preference: env override -> PipeWire -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
if not try_candidates(order):
_LOGGER.warning(
"No suitable audio output (PipeWire/Pulse/ALSA) available for 'default'"
)
else:
# No explicit prefix: respect env override first; otherwise PW -> Pulse -> ALSA
tried = False
if env_ao:
tried = try_candidates(
[(env_ao, f"{env_ao}/{d if env_ao != 'alsa' else 'default'}")]
)
if not tried:
try_candidates(
[
("pipewire", f"pipewire/{d}"),
("pulse", f"pulse/{d}"),
("alsa", "alsa/default"),
]
)
else:
# No device specified: env override first, then PW -> Pulse -> ALSA
order: list[Tuple[str, str]] = []
if env_ao:
order.append((env_ao, f"{env_ao}/default"))
order += [
("pipewire", "pipewire/default"),
("pulse", "pulse/default"),
("alsa", "alsa/default"),
]
try_candidates(order)
self.is_playing = False
# Apply selections (safe setters log on failure)
if ao:
_set_player_opt(player, "ao", ao)
if norm:
_set_player_opt(player, "audio-device", norm)
self._playlist: List[str] = []
if ao or norm:
_LOGGER.debug("mpv backend selected: ao=%s, audio-device=%s", ao, norm)
return ao, norm
# ---- class -------------------------------------------------------------------
class MpvMediaPlayer:
def __init__(self, device: Optional[str] = None, watchdog_sec: float = 0.0) -> None:
# Bridge mpv logs into Python logging
self.player = MPV(video=False, terminal=False, log_handler=self._mpv_log)
# mpv defaults (errors are logged, not swallowed)
_set_player_opt(self.player, "audio-samplerate", 44100)
_set_player_opt(self.player, "audio-channels", "stereo")
_set_player_opt(self.player, "keep-open", "no")
_set_player_opt(self.player, "network-timeout", 7)
msg_level = os.environ.get("LVA_MPV_MSG_LEVEL", _DEFAULT_MSG_LEVEL)
_set_player_opt(self.player, "msg-level", msg_level)
# Env-driven AO override
env_ao = _normalize_env_ao(os.environ.get("LVA_AO"))
# Backend selection
self._ao, self._audio_device = _select_backend(self.player, device, env_ao)
# State
self.is_playing: bool = False
self._done_callback: Optional[Callable[[], None]] = None
self._done_callback_lock = Lock()
self._pre_duck_volume: Optional[int] = None # stores 0..100 when ducked
self._watchdog: Optional[Timer] = None
self._watchdog_sec: float = float(watchdog_sec)
self._duck_volume: int = 50
self._unduck_volume: int = 100
# Playback end detection (both EOF and idle)
self.player.observe_property("eof-reached", self._on_eof)
self.player.observe_property("idle-active", self._on_idle_active)
self.player.event_callback("end-file")(self._on_end_file)
# -------------------- public API --------------------
def play(
self,
url: Union[str, List[str]],
url: Union[str, Sequence[str], bytes],
done_callback: Optional[Callable[[], None]] = None,
stop_first: bool = True,
stop_first: bool = False,
) -> None:
"""Begin playback of a URL or local file. Replaces any existing playback.
Accepts a single URL (str/bytes) or a sequence of URLs; if a sequence is given,
the first item is played.
"""
if stop_first:
try:
self.stop()
except Exception:
_LOGGER.debug("stop_first=True: stop() raised, continuing", exc_info=True)
if isinstance(url, str):
self._playlist = [url]
else:
self._playlist = url
next_url = self._playlist.pop(0)
_LOGGER.debug("Playing %s", next_url)
# Store callback
with self._done_callback_lock:
self._done_callback = done_callback
self.is_playing = True
self.player.play(next_url)
def pause(self) -> None:
self.player.pause = True
self.is_playing = False
# Normalize URL input
if isinstance(url, (list, tuple)):
if not url:
_LOGGER.error("mpv play() received empty URL list")
self._run_done_callback()
return
url = url[0]
def resume(self) -> None:
self.player.pause = False
if self._playlist:
self.is_playing = True
if isinstance(url, bytes):
try:
url = url.decode("utf-8")
except Exception:
url = url.decode(errors="ignore")
def stop(self) -> None:
self.player.stop()
self._playlist.clear()
def duck(self) -> None:
self.player.volume = self._duck_volume
def unduck(self) -> None:
self.player.volume = self._unduck_volume
def set_volume(self, volume: int) -> None:
volume = max(0, min(100, volume))
self.player.volume = volume
self._unduck_volume = volume
self._duck_volume = volume // 2
def _on_end_file(self, event) -> None:
if self._playlist:
self.player.play(self._playlist.pop(0))
if not isinstance(url, str):
_LOGGER.error("mpv play() expected str URL, got %r", type(url))
self._run_done_callback()
return
self.is_playing = False
# Ensure not muted
try:
self.player["mute"] = "no"
except Exception:
_LOGGER.debug("Failed to clear mute before play()", exc_info=True)
todo_callback: Optional[Callable[[], None]] = None
# Begin playback
self.is_playing = True
try:
self.player.play(url)
self._arm_watchdog()
except Exception:
self.is_playing = False
_LOGGER.exception("mpv failed to play %r", url, exc_info=True)
self._run_done_callback()
def pause(self) -> None:
try:
self.player.pause = True
except Exception:
_LOGGER.exception("mpv pause() failed", exc_info=True)
def resume(self) -> None:
try:
self.player.pause = False
except Exception:
_LOGGER.exception("mpv resume() failed", exc_info=True)
def stop(self) -> None:
"""Stop playback immediately and clear playing state; run done callback."""
try:
try:
self.player.command("stop")
except Exception:
# Fallback: load a null source to replace playback
self.player.command("loadfile", "null://", "replace")
finally:
self._cancel_watchdog()
was_playing = self.is_playing
self.is_playing = False
if was_playing:
self._run_done_callback()
def set_volume(self, *args, **kwargs) -> None:
"""Set volume 0..100. Accepts positional or keyword 'volume' (back-compat)."""
vol_arg = args[0] if args else kwargs.get("volume")
if vol_arg is None:
_LOGGER.error("set_volume() requires an integer (0..100)")
return
try:
vol = max(0, min(100, int(vol_arg)))
except Exception:
_LOGGER.exception("set_volume(): invalid value %r", vol_arg, exc_info=True)
return
try:
self.player.volume = vol
except Exception:
_LOGGER.exception("mpv set_volume(%s) failed", vol, exc_info=True)
def duck(self, target_percent: int = 20) -> None:
"""Temporarily lower volume for TTS/alerts; call unduck() to restore."""
if self._pre_duck_volume is not None:
return # already ducked
try:
current = int(round(float(self.player.volume)))
except Exception:
current = 100
self._pre_duck_volume = max(0, min(100, current))
try:
self.player.volume = max(0, min(100, int(target_percent)))
except Exception:
_LOGGER.exception("duck(): failed to set duck volume", exc_info=True)
def unduck(self) -> None:
"""Restore volume after duck()."""
if self._pre_duck_volume is None:
return
try:
self.player.volume = self._pre_duck_volume
except Exception:
_LOGGER.exception("unduck(): failed to restore volume", exc_info=True)
finally:
self._pre_duck_volume = None
# -------------------- callbacks & watchdog --------------------
def _on_eof(self, _name: str, reached: bool) -> None:
if not reached:
return
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _on_idle_active(self, _name: str, active: bool) -> None:
# When playback ends, mpv enters idle; treat that as completion.
if not self.is_playing:
return
if bool(active):
_LOGGER.debug("mpv became idle; treating as end-of-playback")
self._cancel_watchdog()
self.is_playing = False
self._run_done_callback()
def _arm_watchdog(self) -> None:
self._cancel_watchdog()
if self._watchdog_sec <= 0:
return
self._watchdog = Timer(self._watchdog_sec, self._watchdog_trip)
self._watchdog.daemon = True
self._watchdog.start()
def _cancel_watchdog(self) -> None:
if self._watchdog is not None:
try:
self._watchdog.cancel()
except Exception:
_LOGGER.debug("watchdog cancel raised", exc_info=True)
self._watchdog = None
def _watchdog_trip(self) -> None:
if self.is_playing:
_LOGGER.warning(
"mpv watchdog fired after %.1fs; stopping playback", self._watchdog_sec
)
try:
self.player.command("stop")
except Exception:
_LOGGER.debug("watchdog stop raised", exc_info=True)
self.is_playing = False
self._run_done_callback()
def _run_done_callback(self) -> None:
cb: Optional[Callable[[], None]] = None
with self._done_callback_lock:
if self._done_callback:
todo_callback = self._done_callback
cb = self._done_callback
self._done_callback = None
if todo_callback:
if cb is not None:
try:
todo_callback()
cb()
except Exception:
_LOGGER.exception("Unexpected error running done callback")
_LOGGER.exception("Unexpected error running done callback", exc_info=True)
# -------------------- mpv log bridge --------------------
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
msg = f"mpv[{level}] {prefix}: {text}".rstrip()
if level in ("fatal", "error"):
_LOGGER.error(msg)
elif level in ("warn", "warning"):
_LOGGER.warning(msg)
elif level in ("info",):
_LOGGER.info(msg)
else:
_LOGGER.debug(msg)
@@ -0,0 +1,364 @@
import json
import logging
import socket
import threading
from typing import Callable, Optional, List, Dict, Any
from urllib.parse import urlparse
from pathlib import Path
import time
# Models that should NOT appear in UI dropdowns
EXCLUDED_OWW_MODELS = {"embedding_model", "melspectrogram"}
# Models that are not actual wake-word detectors and should never be shown
_OWW_DETECT_MODEL_DENYLIST = {"embedding_model", "melspectrogram"}
import re
def _prettify_model_name(name: str) -> str:
"""
Convert 'hey_jarvis_v0.1' -> 'Hey Jarvis', 'ok_nabu_v0.1' -> 'OK Nabu',
'hal_v2' -> 'Hal', 'marvin_v2' -> 'Marvin'.
"""
stem = Path(name).stem
# Strip common version suffixes
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
return " ".join(parts)
def _normalize_model_name(name: str) -> str:
# Accept "foo_v2", "foo_v2.tflite", full paths, etc.
stem = Path(name).stem
return stem
def _filter_detect_models(models: list[str]) -> list[str]:
# Remove helper models and de-duplicate
normalized = {_normalize_model_name(m) for m in models}
return sorted(m for m in normalized if m not in _OWW_DETECT_MODEL_DENYLIST)
_LOGGER = logging.getLogger(__name__)
def _jsonl(obj: dict) -> bytes:
return (json.dumps(obj, separators=(",", ":")) + "\n").encode("utf-8")
class WyomingWakeClient:
"""Minimal client for Wyoming wake detection (openWakeWord)."""
def __init__(self, uri: str, name: str, rate=16000, width=2, channels=1):
self.uri, self.name = uri, name
self.rate, self.width, self.channels = rate, width, channels
self._paused = False
self._suppress_until_ms = 0
self._refractory_ms = 0
self._sock: Optional[socket.socket] = None
self._reader: Optional[threading.Thread] = None
self._on_detect: Optional[Callable[[str, Optional[int]], None]] = None
self._closed = False
# model discovery cache
self._models_cache: List[Dict[str, Any]] = []
self._info_event = threading.Event()
def connect(self, on_detect: Callable[[str, Optional[int]], None]) -> None:
self._on_detect = on_detect
parsed = urlparse(self.uri)
if parsed.scheme != "tcp":
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
self._sock = socket.create_connection((parsed.hostname or "127.0.0.1", parsed.port or 10400))
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Start audio stream
self._send({"type": "audio-start", "data": {"rate": self.rate, "width": self.width, "channels": self.channels}})
# Ask for model info
try:
self._send({"type": "describe"})
except Exception:
pass
# Set initial detect model
if self.name:
try:
self._send({"type": "detect", "data": {"names": [self.name]}})
except Exception:
pass
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
_LOGGER.debug("Connected to Wyoming wake server at %s", self.uri)
def close(self) -> None:
self._closed = True
try:
self._send({"type": "audio-stop"})
except Exception:
pass
try:
if self._sock:
self._sock.close()
finally:
self._sock = None
def send_audio_chunk(self, pcm: bytes, timestamp_ms: Optional[int] = None) -> None:
if self._sock is None or self._closed:
return
hdr = {"type": "audio-chunk",
"data": {"rate": self.rate, "width": self.width, "channels": self.channels},
"payload_length": len(pcm)}
if timestamp_ms is not None:
hdr["data"]["timestamp"] = timestamp_ms
self._sock.sendall(_jsonl(hdr))
self._sock.sendall(pcm)
def pause(self) -> None:
self._paused = True
def resume(self) -> None:
self._paused = False
def suppress(self, ms: int) -> None:
try:
now_ms = int(time.time() * 1000)
except Exception:
now_ms = 0
self._suppress_until_ms = max(self._suppress_until_ms, now_ms + int(ms))
def set_refractory(self, ms: int) -> None:
self._refractory_ms = max(0, int(ms))
def list_models(self):
"""Query models using a short-lived connection with timeout; accept 'describe' or 'info'."""
parsed = urlparse(self.uri)
if parsed.scheme != "tcp":
raise ValueError(f"Only tcp:// URIs are supported (got {self.uri})")
host, port = (parsed.hostname or "127.0.0.1", parsed.port or 10400)
s = socket.create_connection((host, port))
try:
s.settimeout(1.5)
s.sendall(_jsonl({"type": "describe"}))
f = s.makefile("rb")
while True:
try:
line = f.readline()
except Exception:
break
if not line:
break
try:
msg = json.loads(line.decode("utf-8"))
except Exception:
continue
mtype = msg.get("type")
if mtype in ("describe", "info"):
data = msg.get("data", {}) or {}
wake = data.get("wake", {}) or {}
models = wake.get("models") or wake.get("names") or []
norm: List[Dict[str, Any]] = []
for m in models:
if isinstance(m, str):
norm.append({"name": m})
elif isinstance(m, dict):
norm.append(m)
out: List[Dict[str, Any]] = []
for m in norm:
name = (m.get('name') or '').strip()
if not name or name in EXCLUDED_OWW_MODELS:
continue
phrase = (m.get('phrase') or '').strip()
desc = (m.get('description') or '').strip()
try:
label = phrase or desc or _prettify_model_name(name) # type: ignore[name-defined]
except NameError:
stem = Path(name).stem
stem = re.sub(r"(?:_v\d+(?:\.\d+)?)$", "", stem)
parts = stem.split("_")
if parts and parts[0].lower() == "ok":
parts[0] = "OK"
else:
parts = [p.capitalize() for p in parts]
label = " ".join(parts)
mm = dict(m)
mm['label'] = label
out.append(mm)
return out
finally:
try:
s.close()
except Exception:
pass
return []
def get_models(self, timeout: float = 1.5):
"""Return cached models after waiting briefly for an 'info'/'describe' update."""
self._info_event.wait(timeout)
return list(self._models_cache)
def set_detect(self, names):
if self._sock is None:
raise RuntimeError("Not connected")
filtered = [n for n in list(names) if n]
if not filtered:
_LOGGER.warning("Ignoring empty detect() request (no model names)")
return
self.name = filtered[0]
_LOGGER.debug("Requesting Wyoming detect models: %s", filtered)
self._send({"type": "detect", "data": {"names": filtered}})
# internals
def _send(self, obj: dict) -> None:
assert self._sock is not None
self._sock.sendall(_jsonl(obj))
# --- paste this to replace the whole _read_loop method ---
def _read_loop(self) -> None:
assert self._sock is not None
f = self._sock.makefile("rb")
buf = "" # text buffer for concatenated JSON objects
def _yield_objects_from_text(text: str):
"""Yield JSON objects from `text` even if they are concatenated like {}{}..."""
start = 0
depth = 0
in_str = False
escape = False
for i, ch in enumerate(text):
if in_str:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
# complete object from start..i
yield text[start : i + 1], i + 1
# return here with any remainder unparsed
return
while not self._closed:
chunk = f.readline()
if not chunk:
break
try:
piece = chunk.decode("utf-8")
except Exception:
try:
_LOGGER.debug("Wyoming raw (non-utf8): %r", chunk[:200])
except Exception:
pass
continue
buf += piece
# Try to peel off as many complete JSON objects as we can
pos = 0
progressed = True
while progressed and pos < len(buf):
progressed = False
for obj_text, end_pos in _yield_objects_from_text(buf[pos:]):
progressed = True
pos += end_pos
try:
msg = json.loads(obj_text)
except Exception:
try:
_LOGGER.debug("Wyoming raw (bad-json): %r", obj_text[:200])
except Exception:
pass
continue
mtype = str(msg.get("type") or "").lower()
try:
_LOGGER.debug("Received Wyoming message type=%s", mtype or "<?>")
if mtype and mtype not in ("info", "describe"):
_LOGGER.debug("Wyoming raw: %r", obj_text[:200])
except Exception:
pass
# ----- model info path -----
if mtype in ("info", "describe"):
data = msg.get("data", {}) or {}
wake = data.get("wake", {}) if isinstance(data, dict) else {}
models = []
if isinstance(wake, dict):
if isinstance(wake.get("models"), list):
models = wake.get("models")
elif isinstance(wake.get("names"), list):
models = wake.get("names")
norm = []
for m in models:
if isinstance(m, str):
norm.append({"name": m})
elif isinstance(m, dict):
norm.append(m)
if norm:
self._models_cache = norm
self._info_event.set()
_LOGGER.debug(
"Wyoming model list updated: %s",
[m.get("name") for m in norm],
)
continue
# ----- detection path (accept several shapes) -----
data = msg.get("data", {}) or {}
is_detection = False
if mtype == "detection":
is_detection = True
elif mtype in ("event", "wake", "hotword"):
ev = str(data.get("event") or "").lower()
if ev in ("detection", "wake", "hotword"):
is_detection = True
if is_detection:
name = (
data.get("name")
or data.get("model")
or data.get("wakeword")
or self.name
)
ts = data.get("timestamp") or data.get("time") or data.get("ts")
_LOGGER.debug("Wake detected: %s ts=%s", name, ts)
if self._on_detect:
try:
now_ms = int(time.time() * 1000)
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
else:
now_ms = int(time.time() * 1000)
if self._paused or (self._suppress_until_ms and now_ms < self._suppress_until_ms):
_LOGGER.debug("Detection suppressed (paused=%s until=%s)", self._paused, getattr(self, '_suppress_until_ms', 0))
else:
self._on_detect(name, ts)
if getattr(self, '_refractory_ms', 0):
self.suppress(self._refractory_ms)
if getattr(self, '_refractory_ms', 0):
self.suppress(self._refractory_ms)
except Exception:
_LOGGER.exception("Error in detection callback")
# keep any unconsumed remainder in buffer
buf = buf[pos:]
-1
View File
@@ -29,7 +29,6 @@ dependencies = [
"sounddevice<1",
"numpy>=2,<3",
"pymicro-features==1.0.0",
"python-mpv=>1,<2",
]
[project.optional-dependencies]
+3
View File
@@ -1,2 +1,5 @@
aioesphomeapi
sounddevice
gpiozero
spidev
adafruit-circuitpython-dotstar
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
# Must be run with sudo.
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
set -eo pipefail
kernel_formatted="$(uname -r | cut -f1,2 -d.)"
driver_url_status="$(curl -ILs https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz | tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
if [ ! "$driver_url_status" = 200 ]; then
echo "Could not find driver for kernel $kernel_formatted"
exit 1
fi
apt-get update
apt-get install --no-install-recommends --yes \
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
temp_dir="$(mktemp -d)"
function finish {
rm -rf "${temp_dir}"
}
trap finish EXIT
pushd "${temp_dir}"
# Download source code to temporary directory
# NOTE: There are different branches in the repo for different kernel versions.
echo 'Downloading source code'
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
tar -xzf -
cd seeed-voicecard-"$kernel_formatted"/
# 1. Build kernel module
echo 'Building kernel module'
ver='0.3'
mod='seeed-voicecard'
src='./'
kernel="$(uname -r)"
marker='0.0.0'
threads="$(getconf _NPROCESSORS_ONLN)"
memory="$(LANG=C free -m|awk '/^Mem:/{print $2}')"
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
threads=2
fi
mkdir -p "/usr/src/${mod}-${ver}"
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
dkms add -m "${mod}" -v "${ver}"
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
}
mkdir -p "/var/lib/dkms/${mod}/${ver}/${marker}"
# 2. Install kernel module and configure
echo 'Updating boot configuration'
config='/boot/config.txt'
cp seeed-*-voicecard.dtbo /boot/overlays
grep -q "^snd-soc-ac108$" /etc/modules || echo "snd-soc-ac108" >> /etc/modules
sed -i -e 's:#dtparam=i2c_arm=on:dtparam=i2c_arm=on:g' "${config}"
echo "dtoverlay=i2s-mmap" >> "${config}"
echo "dtparam=i2s=on" >> "${config}"
mkdir -p /etc/voicecard
cp *.conf *.state /etc/voicecard
cp seeed-voicecard /usr/bin/
cp seeed-voicecard.service /lib/systemd/system/
systemctl enable --now seeed-voicecard.service
echo 'Done. Please reboot the system.'
popd
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
PORT=6053
NAME="%h"
SERVICE=/etc/avahi/services/esphome-lva.service
# Wait up to ~40s for any global IPv4 (cosmetic: to include ipv4_* TXT)
deadline=$((SECONDS+40))
while [ $SECONDS -lt $deadline ]; do
ip -o -4 addr show up scope global | grep -q . && break || true
sleep 1
done
# Collect all UP, non-loopback ifaces
mapfile -t IFACES < <(ip -o link show up | awk -F': ' '{print $2}' | grep -v '^lo$' | sort -u)
# Detect platform/board from the running system
PLATFORM="LINUX"
ARCH_RAW="$(uname -m)"
case "$ARCH_RAW" in
aarch64|arm64) BOARD="aarch64" ;;
x86_64|amd64) BOARD="x86_64" ;;
*) BOARD="$ARCH_RAW" ;;
esac
TXT=" <txt-record>platform=${PLATFORM}</txt-record>
"
TXT+=" <txt-record>board=${BOARD}</txt-record>
"
LIST=""
for i in "${IFACES[@]}"; do
[ -e "/sys/class/net/$i/address" ] || continue
mac=$(cat "/sys/class/net/$i/address")
mac_nc=${mac//:/} # no colons (ESPHome style)
if [ -d "/sys/class/net/$i/wireless" ] || [[ "$i" == wl* ]]; then net=wifi; else net=ethernet; fi
TXT+=" <txt-record>network=${net}</txt-record>
"
TXT+=" <txt-record>mac=${mac_nc}</txt-record>
"
LIST+="${i},"
done
LIST=${LIST%,}
cat >"$SERVICE" <<XML
<?xml version="1.0" standalone='no'?><!--*-nxml-*-->
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">${NAME}</name>
<service protocol="ipv4">
<type>_esphomelib._tcp</type>
<port>${PORT}</port>
<txt-record>version=2025.9.0</txt-record>
${TXT} </service>
</service-group>
XML
# Avahi will (re)publish on all active ifaces automatically
systemctl restart avahi-daemon || true
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import subprocess
def list_devices(ao="pulse"):
result = subprocess.run(
["mpv", f"--ao={ao}", "--audio-device=help"],
capture_output=True,
text=True
)
print(result.stdout)
if __name__ == "__main__":
print("PulseAudio devices:")
list_devices("pulse")
print("\nALSA devices:")
list_devices("alsa")
+3
View File
@@ -21,6 +21,9 @@ builder.create(_VENV_DIR)
pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
subprocess.check_call(pip + ["install", "--upgrade", "python-mpv"])
subprocess.check_call(pip + ["install", "--upgrade", "gpiozero"])
subprocess.check_call(pip + ["install", "--upgrade", "spidev"])
# Install requirements
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
+3
View File
@@ -0,0 +1,3 @@
[Unit]
Wants=avahi-daemon.service
After=avahi-daemon.service
+8
View File
@@ -0,0 +1,8 @@
# /etc/systemd/system/linux-voice-assistant.service.d/10-tuning.conf
[Service]
# Keep mpv logs at warning by default; override with environment if desired
Environment=LVA_MPV_MSG_LEVEL=all=warn
# Faster, cleaner shutdowns
TimeoutStopSec=5s
KillMode=mixed
@@ -0,0 +1,27 @@
[Unit]
Description=Linux Voice Assistant (ALSA - ReSpeaker 2mic HAT)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
# Use ALSA ReSpeaker 2mic HAT hardware device
Environment="MPV_DEFAULT_DEVICE=alsa/plughw:CARD=seeed2micvoicec,DEV=0"
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite ALSA MWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,31 @@
[Unit]
Description=Linux Voice Assistant (ALSA - ReSpeaker 2mic HAT)
After=network-online.target wyoming-openwakeword.service
Wants=network-online.target
Requires=wyoming-openwakeword.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
# Use ALSA ReSpeaker 2mic HAT hardware device
Environment="MPV_DEFAULT_DEVICE=alsa/plughw:CARD=seeed2micvoicec,DEV=0"
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite ALSA OWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models'
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,33 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio - Headphone Jack)
After=network-online.target pulseaudio.service
Wants=network-online.target
Requires=pulseaudio.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
# Environment
Environment=PYTHONUNBUFFERED=1
Environment="PULSE_SERVER=unix:/var/run/pulse/native"
Environment="PULSE_LATENCY_MSEC=60"
Environment="MPV_DEFAULT_DEVICE=pulse/alsa_output.platform-soc_sound.stereo-fallback"
# Wait briefly for the Pulse native socket to be ready
ExecStartPre=/bin/sh -c 'for i in $(seq 1 40); do [ -S /var/run/pulse/native ] && exit 0; sleep 0.5; done; echo "PulseAudio socket not found" >&2; exit 1'
# Launch the assistant
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA MWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,36 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio - Headphone Jack)
After=network-online.target pulseaudio.service wyoming-openwakeword.service
Wants=network-online.target
Requires=pulseaudio.service wyoming-openwakeword.service
[Service]
Type=simple
User=pi
Group=pi
WorkingDirectory=/home/pi/linux-voice-assistant
# Environment
Environment=PYTHONUNBUFFERED=1
Environment="PULSE_SERVER=unix:/var/run/pulse/native"
Environment="PULSE_LATENCY_MSEC=60"
Environment="MPV_DEFAULT_DEVICE=pulse/alsa_output.platform-soc_sound.stereo-fallback"
# Wait briefly for the Pulse native socket to be ready
ExecStartPre=/bin/sh -c 'for i in $(seq 1 40); do [ -S /var/run/pulse/native ] && exit 0; sleep 0.5; done; echo "PulseAudio socket not found" >&2; exit 1'
# Launch the assistant
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA OWW' \
--audio-input-device default \
--audio-output-device ${MPV_DEFAULT_DEVICE} \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=PulseAudio system server
[Service]
Type=notify
ExecStart=pulseaudio --daemonize=no --system --realtime --log-target=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,21 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio + MWW)
After=pulseaudio.service
Wants=pulseaudio.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA' \
--audio-input-device default \
--audio-output-device pulse/alsa_output.platform-soc_sound.stereo-fallback \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,26 @@
[Unit]
Description=Linux Voice Assistant (PulseAudio + OWW)
After=pulseaudio.service wyoming-openwakeword.service
Wants=pulseaudio.service wyoming-openwakeword.service
Requires=wyoming-openwakeword.service
After=wyoming-openwakeword.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PA' \
--audio-input-device default \
--audio-output-device pulse/alsa_output.platform-soc_sound.stereo-fallback \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,21 @@
[Unit]
Description=Linux Voice Assistant (PipeWire + MWW)
After=pipewire.service pipewire-pulse.service
Wants=pipewire-pulse.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite PW MWW' \
--audio-input-device default \
--audio-output-device pipewire/default \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,26 @@
[Unit]
Description=Linux Voice Assistant (PipeWire + OWW)
After=pipewire.service pipewire-pulse.service wyoming-openwakeword.service
Wants=pipewire-pulse.service wyoming-openwakeword.service
Requires=wyoming-openwakeword.service
After=wyoming-openwakeword.service
[Service]
Type=simple
WorkingDirectory=/home/pi/linux-voice-assistant
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/linux-voice-assistant/script/run \
--name 'Linux Satellite Test PW' \
--audio-input-device default \
--audio-output-device pipewire/default \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'alexa' \
--wake-models-dir '/home/pi/wyoming-openwakeword/wyoming_openwakeword/models' \
--debug
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
@@ -0,0 +1,25 @@
[Unit]
Description=Wyoming openWakeWord
[Service]
Type=simple
WorkingDirectory=/home/pi/wyoming-openwakeword
ExecStart=/usr/bin/env bash -lc '\
cd /home/pi/wyoming-openwakeword && \
exec ./script/run \
--uri tcp://0.0.0.0:10400 \
--models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models \
\
--preload-model alexa_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_jarvis_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_mycroft_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_rhasspy_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model ok_nabu_v0.1 --threshold 0.5 --trigger-level 3 \
'
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
+27
View File
@@ -0,0 +1,27 @@
# /etc/systemd/system/wyoming-openwakeword.service
[Unit]
Description=Wyoming openWakeWord
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/wyoming-openwakeword
ExecStart=/usr/bin/env bash -lc '\
cd /home/pi/wyoming-openwakeword && \
exec ./script/run \
--uri tcp://0.0.0.0:10400 \
--models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models \
\
--preload-model alexa_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_jarvis_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_mycroft_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model hey_rhasspy_v0.1 --threshold 0.5 --trigger-level 3 \
--preload-model ok_nabu_v0.1 --threshold 0.5 --trigger-level 3 \
'
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
Binary file not shown.
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
tflite-runtime-nightly
wyoming==1.7.2
numpy<2
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import os
import time
import argparse
from pathlib import Path
def try_import_tflite():
try:
from tflite_runtime.interpreter import Interpreter
return Interpreter
except ImportError:
try:
from ai_edge_litert.interpreter import Interpreter
return Interpreter
except ImportError:
raise ImportError("Neither tflite_runtime nor ai_edge_litert is installed.")
def test_model(model_path, Interpreter):
try:
start = time.time()
interpreter = Interpreter(model_path=str(model_path))
interpreter.allocate_tensors()
elapsed = time.time() - start
return True, f"OK (loaded in {elapsed:.2f}s)"
except Exception as e:
return False, f"FAIL ({str(e)})"
def main():
parser = argparse.ArgumentParser(description="Test all TFLite models in a directory.")
parser.add_argument("--models-dir", type=str, default=str(Path.home() / "wyoming-openwakeword" / "wyoming_openwakeword" / "models"),
help="Path to the models directory")
parser.add_argument("--skip-non-detection", action="store_true",
help="Skip known non-detection models (embedding_model.tflite, melspectrogram.tflite)")
parser.add_argument("--quiet", action="store_true", help="Only show failures")
parser.add_argument("--csv", type=str, help="Output results to CSV file")
args = parser.parse_args()
models_dir = Path(args.models_dir)
Interpreter = try_import_tflite()
if not models_dir.exists():
print(f"Models directory not found: {models_dir}")
return
skip_list = {"embedding_model.tflite", "melspectrogram.tflite"}
results = []
for model_file in sorted(models_dir.glob("*.tflite")):
if args.skip_non_detection and model_file.name in skip_list:
if not args.quiet:
print(f"SKIP {model_file.name}")
continue
ok, message = test_model(model_file, Interpreter)
results.append((model_file.name, ok, message))
if not args.quiet or not ok:
status = "PASS" if ok else "FAIL"
print(f"{status} {model_file.name}: {message}")
if args.csv:
import csv
with open(args.csv, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["Model", "Status", "Message"])
for name, ok, msg in results:
writer.writerow([name, "PASS" if ok else "FAIL", msg])
print(f"Results written to {args.csv}")
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
cd wyoming-openwakeword/
source .venv/bin/activate
python3 ~/linux-voice-assistant/wyoming-openwakeword/test_oww_models.py --skip-non-detection --models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models
deactivate
# basic
python3 test_oww_models.py
# skip non-detection models
python3 test_oww_models.py --skip-non-detection
# specify a custom folder
python3 test_oww_models.py --models-dir /home/pi/wyoming-openwakeword/wyoming_openwakeword/models
# quiet mode + CSV
python3 test_oww_models.py --quiet --csv /home/pi/model_check.csv