Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d844f8807 | ||
|
|
0d6b87739a | ||
|
|
68c7930405 | ||
|
|
7abceb946e | ||
|
|
5b1ef20656 | ||
|
|
c527f60681 | ||
|
|
4b6b6dd380 | ||
|
|
67abca1111 | ||
|
|
d10cf87eef | ||
|
|
1da9ac1fac | ||
|
|
fed867936a | ||
|
|
90fa3a9df7 | ||
|
|
05817ceb58 | ||
|
|
010e5447df | ||
|
|
6866358dfe | ||
|
|
7891997392 | ||
|
|
cc2a7a6206 | ||
|
|
1a1c15f836 | ||
|
|
cfc24f6b73 | ||
|
|
4da78c7435 | ||
|
|
8a9939cf62 | ||
|
|
eb4c4c26c8 | ||
|
|
a1b460391d | ||
|
|
0f3c7d6c15 | ||
|
|
b8a3ce2c39 | ||
|
|
3ba4e748ee | ||
|
|
5932f945e9 | ||
|
|
5315c56556 | ||
|
|
a7a4f47f52 | ||
|
|
4f05312e16 | ||
|
|
7bbe0ed96f | ||
|
|
4bea3f63d2 | ||
|
|
77c2299453 | ||
|
|
ae9b834ee3 | ||
|
|
bddbbbe899 | ||
|
|
661e50921e | ||
|
|
670cd52725 | ||
|
|
22e94fc92b | ||
|
|
99157b9a8a | ||
|
|
46c39c6ae5 | ||
|
|
ab99019d42 | ||
|
|
7abce1060d | ||
|
|
56572d4b33 | ||
|
|
8187760b39 | ||
|
|
3f05bcfc56 | ||
|
|
31b6d70910 | ||
|
|
1cd861454a | ||
|
|
f1d2700fac | ||
|
|
70079d6a61 | ||
|
|
957d3f2839 | ||
|
|
ae8f1c5c3a | ||
|
|
3d7cc49466 | ||
|
|
b15848a97d | ||
|
|
59d8071384 | ||
|
|
e845c5c811 | ||
|
|
a52b503cdb | ||
|
|
13aad43c29 | ||
|
|
ebe93da7cb | ||
|
|
6c65903425 | ||
|
|
8d135dc51b | ||
|
|
7305a289a0 | ||
|
|
44091bbc8b | ||
|
|
97da7f2d0f | ||
|
|
471ecea811 | ||
|
|
186f33f1a1 | ||
|
|
57b20a1ef1 | ||
|
|
5ef680d734 | ||
|
|
5773f3a35a | ||
|
|
6260bf1dca | ||
|
|
fa3889805e | ||
|
|
a9cf822136 | ||
|
|
0073dc6a23 | ||
|
|
dc9049e899 | ||
|
|
edc66183c3 | ||
|
|
93bdb60fc0 | ||
|
|
912468703a | ||
|
|
17f1671f1b | ||
|
|
0e928bd7b3 | ||
|
|
0025fc781f | ||
|
|
5da1dd4bc4 | ||
|
|
b43b036198 | ||
|
|
5931370c63 | ||
|
|
53d143fb35 | ||
|
|
be41c876b0 | ||
|
|
48a6ec38a5 | ||
|
|
b43cd95ec1 | ||
|
|
e1fd1ccd4d | ||
|
|
5ce9085fd9 | ||
|
|
d0a74a2d3b | ||
|
|
85560b8126 | ||
|
|
0a2c227cad | ||
|
|
11c0e4fbb6 | ||
|
|
e13cc31e5b | ||
|
|
e3f9757bee | ||
|
|
f38c89fb3b | ||
|
|
c6c34b60e5 | ||
|
|
62164e0142 | ||
|
|
bd488f555e | ||
|
|
a9989f0c25 | ||
|
|
baa6f9080f | ||
|
|
f3ae02da0d | ||
|
|
14d10b351f | ||
|
|
d2a133191b | ||
|
|
8d474acdab | ||
|
|
18e9a49adf | ||
|
|
61e02f9adb | ||
|
|
93ceffebae | ||
|
|
9285337a5e | ||
|
|
c7af116904 | ||
|
|
25d341927c | ||
|
|
adbb37e731 | ||
|
|
0151b64810 | ||
|
|
8d928c6b02 | ||
|
|
0b3c3e7b42 | ||
|
|
f34bc90edd | ||
|
|
196d5273f0 | ||
|
|
d3d60e1c99 | ||
|
|
4616645c40 | ||
|
|
6eb3f46cbe | ||
|
|
6f5444237c | ||
|
|
cf82a3158d | ||
|
|
e63bda7e76 | ||
|
|
592d5eb0c3 | ||
|
|
360fe06d96 | ||
|
|
efe2b4ff4e | ||
|
|
e3216d3e68 | ||
|
|
5eb1489947 | ||
|
|
2808877895 | ||
|
|
2fdef7d6ce | ||
|
|
2ccda5ac4a | ||
|
|
fd5cd13f72 | ||
|
|
ada83bdfbb | ||
|
|
b62d07be7d | ||
|
|
f39df8a40a | ||
|
|
37922351f6 | ||
|
|
64e2657824 | ||
|
|
8c6df9b245 | ||
|
|
52e5fb374e | ||
|
|
fc6d750257 | ||
|
|
4d8f5c97b7 | ||
|
|
c9be5fc026 | ||
|
|
a4fa7b8631 | ||
|
|
005900b2ae | ||
|
|
324881ca8b | ||
|
|
f033595649 | ||
|
|
d2a9057dde | ||
|
|
d95c15bf40 | ||
|
|
8d6b1849bd | ||
|
|
19677dfedd | ||
|
|
e0bb8e7793 | ||
|
|
82c02e9db9 | ||
|
|
9bc2830a2d | ||
|
|
3507074698 | ||
|
|
bf39055441 | ||
|
|
3587d8329f | ||
|
|
d07e1be653 | ||
|
|
28c4ade611 | ||
|
|
90f37df4e8 | ||
|
|
8e4480716a | ||
|
|
531e6500d0 | ||
|
|
70e51148bf | ||
|
|
9d27b016ca | ||
|
|
b209c1300e | ||
|
|
e073be7978 | ||
|
|
75d02225b0 | ||
|
|
77c2530657 | ||
|
|
bd24e543b9 | ||
|
|
ebd74c1e80 | ||
|
|
ce5418a27a | ||
|
|
94e13f2c13 | ||
|
|
bd21743c49 | ||
|
|
474ddfbaa4 | ||
|
|
208e7baec6 | ||
|
|
899130c0d5 | ||
|
|
2ab4ae7b22 | ||
|
|
4d153d41cd | ||
|
|
ab4bc2d027 | ||
|
|
a1c1336ba2 | ||
|
|
f1137232e0 | ||
|
|
0f1ceefc5c | ||
|
|
2cdabdad30 | ||
|
|
64c46fce2d | ||
|
|
943a8a0d5c | ||
|
|
61bf13ca98 | ||
|
|
111f99983e | ||
|
|
a74bf2ca1d | ||
|
|
676591a75d | ||
|
|
4cbfc760f3 | ||
|
|
8b30c3cecb | ||
|
|
c4f632a48f | ||
|
|
8bef7e0f7a | ||
|
|
d427d138e6 | ||
|
|
85f4d9150b | ||
|
|
9547a8eea6 | ||
|
|
ce4486896a | ||
|
|
2dc8e146af | ||
|
|
31ea2762a3 | ||
|
|
d98ed168a4 | ||
|
|
8cd9ffc0d5 | ||
|
|
6f63586ec5 | ||
|
|
57ac85b044 | ||
|
|
505f53315d | ||
|
|
957a56c0b5 | ||
|
|
523bdd74c4 | ||
|
|
24c0d30540 | ||
|
|
c067f6de99 | ||
|
|
bfef126e0d | ||
|
|
49e75d0b2f | ||
|
|
8b8de94c76 | ||
|
|
5c14c6c782 | ||
|
|
f707f3232a | ||
|
|
6077a31baa | ||
|
|
dbc371ce8f | ||
|
|
798c371943 | ||
|
|
26cdd5159b | ||
|
|
37898c8241 | ||
|
|
939dd8963c | ||
|
|
58be335eb7 | ||
|
|
a26e91eb70 | ||
|
|
7e4686b8f6 | ||
|
|
7d4edbf369 | ||
|
|
6958b09f4d | ||
|
|
1eef4ba917 | ||
|
|
226dc7f11d | ||
|
|
a435cc97e7 | ||
|
|
402a6d1855 | ||
|
|
e0bef20398 | ||
|
|
53b8fc4da9 | ||
|
|
824ac111ff | ||
|
|
bad38a3ece | ||
|
|
f90ecdcda3 | ||
|
|
01c49d4888 | ||
|
|
515ce2c5b2 | ||
|
|
3fb4ee8066 | ||
|
|
524ad02228 | ||
|
|
d071803257 | ||
|
|
6868651823 | ||
|
|
834a15beb9 | ||
|
|
e78675aec2 | ||
|
|
ccf5af8875 | ||
|
|
13c64687fe | ||
|
|
82290688e2 | ||
|
|
bb4556a2ec | ||
|
|
98c8585ae3 | ||
|
|
aecf118983 | ||
|
|
63862487fb | ||
|
|
3d07872f49 | ||
|
|
ca34354948 | ||
|
|
6a4163d32a | ||
|
|
085e944a85 | ||
|
|
eda9ac7d0d | ||
|
|
b3a853b94b | ||
|
|
7049ec969e | ||
|
|
541496e852 | ||
|
|
8ccfbdec17 | ||
|
|
02494e3bbd | ||
|
|
7b74e25759 | ||
|
|
1ccd9b35c8 | ||
|
|
81f3cb5425 | ||
|
|
cd4ad4bdfc | ||
|
|
3e99e651f1 | ||
|
|
d5b1507379 | ||
|
|
355f8c8150 | ||
|
|
5fcb8b2623 | ||
|
|
1a5cc3005f | ||
|
|
002c22c622 | ||
|
|
a7c797991b | ||
|
|
d90943fe8e | ||
|
|
39d6d3e010 | ||
|
|
4d12149dae | ||
|
|
415130aeb4 | ||
|
|
bceb7640ef | ||
|
|
3e52720925 | ||
|
|
507fe66032 | ||
|
|
9811328182 | ||
|
|
aa3ebff8f4 | ||
|
|
081ebd4103 | ||
|
|
18f29c52da | ||
|
|
92867db2b2 | ||
|
|
efbcbc599f | ||
|
|
6e63111e63 | ||
|
|
d5eef16235 | ||
|
|
164e01682f | ||
|
|
2c5a881736 | ||
|
|
e7c9b58ef4 | ||
|
|
b195cfde56 | ||
|
|
688cc97d7d | ||
|
|
6ce1c5f2b7 | ||
|
|
abcc7aeaef | ||
|
|
7f8bdefa39 | ||
|
|
de30740dce | ||
|
|
dfe992ec35 | ||
|
|
fd54c25303 | ||
|
|
710610035c | ||
|
|
70ca927617 | ||
|
|
21a0108a82 | ||
|
|
a463709ef2 | ||
|
|
abcbae1b8f | ||
|
|
6a66bd7128 | ||
|
|
0a44238655 | ||
|
|
ec51b0b11e | ||
|
|
bc8e6e0ef7 | ||
|
|
87a7cfa7b2 | ||
|
|
1baae464d1 | ||
|
|
853e25cf60 | ||
|
|
eb73cb5baa | ||
|
|
ab8f09048e | ||
|
|
68992feb9a | ||
|
|
7a4b3e661a | ||
|
|
ed16301767 | ||
|
|
d5300ae694 | ||
|
|
f520459022 | ||
|
|
e26864a191 | ||
|
|
2c937e3cb3 | ||
|
|
ea901a1c14 |
+71
-14
@@ -1,18 +1,75 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg
|
||||
build
|
||||
htmlcov
|
||||
*$py.class
|
||||
|
||||
.projectile
|
||||
.venv/
|
||||
venv/
|
||||
.tox/
|
||||
.mypy_cache/
|
||||
*.egg-info/
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a script, but we don't want them in .git
|
||||
*.spec
|
||||
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Foreign VSCode settings
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Ruff cache
|
||||
.ruff_cache/
|
||||
|
||||
# Pyright cache
|
||||
.pyright_cache/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# venv
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# App-specific
|
||||
/dist/
|
||||
/preferences.json
|
||||
/local/
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add zeroconf/mDNS discovery
|
||||
- Support openWakeWord
|
||||
- Support multiple wake words
|
||||
- Save active wake words to preferences JSON file
|
||||
- Refactor main into separate files
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Initial release
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
# 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/API (via [aioesphomeapi](https://github.com/esphome/aioesphomeapi)).
|
||||
|
||||
Runs on Linux `aarch64` and `x86_64` platforms. Tested with Python 3.14, 3.13, and 3.11.
|
||||
|
||||
**Confirmed working on Rasberry OS (Trixie), Fedora, and Arch**
|
||||
|
||||
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).
|
||||
|
||||
The LVA now supports the ReSpeaker XVF3800 4 Mic array via the USB interface. See [this tutorial](docs/linux-voice-assistant-xvf3800.md).
|
||||
|
||||
Want to run the satellite on a Linux desktop using a simple tray client? See [this tutorial](docs/lva-desktop.md).
|
||||
|
||||
### What's working:
|
||||
- This fork is from https://github.com/OHF-Voice/linux-voice-assistant Release v1.0.0 which introduces the ability to use both MicroWakeWord and OpenWakeWord detections models.
|
||||
- Refactor: Sync with Upstream architectural changes, includes the 7 commits since the release of v1.0.0.
|
||||
- **Core Refactor (Nov 2025):** Major architectural cleanup moving audio logic to a dedicated engine.
|
||||
- **Added ReSpeaker 2-Mic Button Support:** The GPIO button (17) can be enabled via the config.json file. See [the tutorial](docs/linux-voice-assistant-2mic-install.md).
|
||||
- **Changed Alarm to include a Duration Setting:** Added a MQTT control to make the alarm duration adjustable.
|
||||
- **Performance Optimization:** Optimized audio threading for lower CPU usage when muted and improved responsiveness (reduced latency) when unmuted.
|
||||
- **Non-Blocking Operations:** Wake word model downloads are now handled in background threads, preventing the device (LEDs/MQTT) from freezing during configuration updates.
|
||||
- Updated to support LED Events including GPIO based LED controls. Defaults to the ReSpeaker 2Mic Hat SPI leds, but you can use the Grove port GPIO12/13 by updating the config.json file.
|
||||
- Updated to support running either APA102 or WS2812B LEDs from the SPI interface using a Micro Connectors 40-pin GPIO 1 to 2 Expansion Board. See the tutorial for instructions.
|
||||
- You can choose between all MWW and OWW wake word within HA after the VLA is registered. Chosen wake words are saved to preferences.json in the linux-voice-assistant folder.
|
||||
- The volume control is now persistent between connections and reboots. The volume setting gets stored in preferences.json and loaded when LVA starts.
|
||||
- **Acoustic Echo Cancellation (AEC) using WebRTC.** See [the tutorial](docs/linux-voice-assistant-2mic-install.md) for instructions on implementation and tuning.
|
||||
|
||||
### Add Full MQTT Control for LEDs and Mute
|
||||
- This branch introduces a comprehensive MQTT integration to bypass limitations in the pinned aioesphomeapi library and provide full remote control over the voice satellite's features and appearance.
|
||||
|
||||
- It uses MQTT Discovery to automatically create and configure a device and its associated entities within Home Assistant. This allows for real-time control from the HA interface and enables powerful automations.
|
||||
|
||||
#### Key Features:
|
||||
|
||||
- A switch entity to mute and unmute the microphone.
|
||||
|
||||
- A full suite of select and light entities to customize the effect, color, and brightness for each voice assistant state (Idle, Listening, Thinking, Responding, Error).
|
||||
|
||||
- A number entity to configure the number of LEDs in the strip, allowing for use with custom hardware.
|
||||
|
||||
- A number entity to configure the duration of the alarm in seconds. A setting of 0 leaves the alarm in (until stopped) mode.
|
||||
|
||||
- All settings are persistent, retained by the MQTT broker and re-applied whenever the application restarts.
|
||||
|
||||
See [the tutorial](docs/linux-voice-assistant-2mic-install.md) to enable MQTT Controls.
|
||||
|
||||
## 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:
|
||||
|
||||
``` sh
|
||||
git clone https://github.com/OHF-Voice/linux-voice-assistant.git
|
||||
git clone https://github.com/imonlinux/linux-voice-assistant.git
|
||||
cd linux-voice-assistant
|
||||
script/setup
|
||||
```
|
||||
@@ -25,26 +69,9 @@ 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 `linux_voice_assistant/config.json.example` for more options.
|
||||
|
||||
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
|
||||
## Connecting to Home Assistant if not auto detected
|
||||
|
||||
1. In Home Assistant, go to "Settings" -> "Device & services"
|
||||
2. Click the "Add integration" button
|
||||
@@ -52,6 +79,23 @@ 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/PulseAudio.~~ (Taken from upstream and successfully tested)
|
||||
* ~~Merge jianyu-li's PR from source project to add mute switch function in this branch~~
|
||||
* ~~Implement MQTT entities to support advanced controls of the LVA.~~
|
||||
* ~~Configure LVA to advertise on Zeroconf/mDNS via Avahi for HA to auto detect (in progress)~~ (Not needed as Release v1.0.0 implemented in code)
|
||||
* ~~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~~ (Not needed as you can use both at the same time)
|
||||
* Could this be an DEB package?
|
||||
* ~~Implement Stop for OWW~~ (Not needed, Stop with MWW works even when using OWW detection models)
|
||||
* 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/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# XVF3800 permissions
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", MODE="0660", GROUP="plugdev", TAG+="uaccess"
|
||||
|
||||
# XVF3800 power policy
|
||||
ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="2886", ATTR{idProduct}=="001a", TEST=="power/control", ATTR{power/control}="on"
|
||||
@@ -0,0 +1,89 @@
|
||||
>
|
||||
> 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
|
||||
systemctl --user stop linux-voice-assistant.service
|
||||
systemctl --user disable --now 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
|
||||
```
|
||||
|
||||
**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.**
|
||||
|
||||
|
||||
## 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 100%.
|
||||
|
||||
```sh
|
||||
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
|
||||
```
|
||||
|
||||
|
||||
## 9. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
## Install PulseAudio On Your Wyoming Voice Satellite
|
||||
|
||||
> Courtesy of https://github.com/FutureProofHomes/wyoming-enhancements/blob/master/snapcast/docs/2_install_pulseaudio.md with now major 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. (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
|
||||
```
|
||||
|
||||
|
||||
## 7. Test and make sure you can hear the wav file.:
|
||||
|
||||
**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.**
|
||||
|
||||
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 volume is low, set it to 100%.
|
||||
|
||||
```sh
|
||||
pactl set-sink-volume alsa_output.platform-soc_sound.stereo-fallback 100%
|
||||
```
|
||||
|
||||
|
||||
### 8. Done! Return to the tutorial to continue the Linux Voice Assistant install.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
# 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** and **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) (Bookworm or Trixie)
|
||||
- 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 build-essential git \
|
||||
libmpv-dev mpv python3-dev python3-venv
|
||||
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. Linux Voice Assistant (LVA)
|
||||
|
||||
```bash
|
||||
cd ~/linux-voice-assistant/
|
||||
script/setup
|
||||
```
|
||||
|
||||
|
||||
## 5. Choose your install option "Choose your Adventure!"
|
||||
|
||||
Pick **one** of the following install paths. Expand a section to see the exact steps.
|
||||
|
||||
> Tip: All services run in *user* mode (requires `loginctl enable-linger`);
|
||||
|
||||
<details>
|
||||
<summary><strong>PipeWire (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 LVA user-mode services:**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
```
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.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 (user-mode services)</strong></summary>
|
||||
|
||||
**Prep (PulseAudio):** Follow the PulseAudio tutorial first: [the tutorial](install_pulseaudio.md).
|
||||
|
||||
**Enable linger (required for user services to start after reboot):**
|
||||
```bash
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install LVA user-mode services:**
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
```
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.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>ALSA (user-mode 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
|
||||
```
|
||||
|
||||
**Enable linger (required for user services to start after reboot):**
|
||||
```bash
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
**Install LVA user-mode services:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
```
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant.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>Optional (MQTT Controls)</strong></summary>
|
||||
|
||||
|
||||
## 🔌 MQTT Controls Overview
|
||||
|
||||
The Linux Voice Assistant (LVA) creates several MQTT entities under the Home Assistant MQTT Discovery protocol. Each entity is prefixed by your LVA's unique device ID defined with the --name field in the LVA User-Mode Serviced Unit (e.g., `linux_voice_assistant`).
|
||||
|
||||
---
|
||||
|
||||
### 🎤 Microphone Mute (`switch` entity)
|
||||
|
||||
Controls the microphone mute state.
|
||||
|
||||
* **MQTT Discovery Topic:** `homeassistant/switch/<device_id>_mute/config`
|
||||
* **Name:** `[LVA Name] Mute Microphone`
|
||||
* **Icon:** `mdi:microphone-off`
|
||||
* **Functionality:** Toggles the microphone mute. When muted, the LEDs will display a dim red `solid` effect.
|
||||
|
||||
---
|
||||
|
||||
## 💡 LED Control for Linux Voice Assistant
|
||||
These are MQTT entities created by the Linux Voice Assistant for controlling its integrated LEDs (e.g., DotStar, NeoPixel). These entities integrate seamlessly with Home Assistant via MQTT Discovery, allowing you to manage LED effects, colors, brightness, and the number of connected LEDs directly from your Home Assistant interface.
|
||||
|
||||
---
|
||||
|
||||
### 🔢 Number of LEDs (`number` entity)
|
||||
|
||||
This entity allows you to specify the physical number of addressable LEDs connected to your device.
|
||||
|
||||
|
||||
* **MQTT Discovery Topic:** `homeassistant/number/<device_id>_num_leds/config`
|
||||
* **Name:** `[LVA Name] Number of LEDs`
|
||||
* **Functionality:** Sets the total number of LEDs.
|
||||
* **Important:** This setting requires a **restart of the LVA service file** to take effect, as the LED hardware driver needs to be re-initialized. The value is persisted in `preferences.json`.
|
||||
|
||||
---
|
||||
|
||||
### ✨ State-Based LED Controls (`select` and `light` entities)
|
||||
|
||||
For each distinct LVA state (Idle, Listening, Thinking, Responding, Error), a `select` entity for choosing an effect and a `light` entity for controlling color and brightness are created.
|
||||
|
||||
* **States:**
|
||||
* `idle` (e.g., `[LVA Name] Idle Effect`, `[LVA Name] Idle Color`)
|
||||
* `listening` (e.g., `[LVA Name] Listening Effect`, `[LVA Name] Listening Color`)
|
||||
* `thinking` (e.g., `[LVA Name] Thinking Effect`, `[LVA Name] Thinking Color`)
|
||||
* `responding` (e.g., `[LVA Name] Responding Effect`, `[LVA Name] Responding Color`)
|
||||
* `error` (e.g., `[LVA Name] Error Effect`, `[LVA Name] Error Color`)
|
||||
|
||||
#### `select` Entities (Effect Selector)
|
||||
|
||||
* **MQTT Discovery Topic:** `homeassistant/select/<device_id>_<state_name>_effect/config`
|
||||
* **Name:** `[LVA Name] [State Name] Effect`
|
||||
* **Icon:** `mdi:palette-swatch-variant`
|
||||
* **Functionality:** Allows selection of an animation/effect for the specific LVA state.
|
||||
|
||||
#### `light` Entities (Color & Brightness Control)
|
||||
|
||||
* **MQTT Discovery Topic:** `homeassistant/light/<device_id>_<state_name>_color/config`
|
||||
* **Name:** `[LVA Name] [State Name] Color`
|
||||
* **Functionality:** Controls the color and brightness for the specific LVA state when the selected effect uses color. Supports RGB color mode and brightness.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Available LED Effects
|
||||
|
||||
The following effects can be selected via the `[State Name] Effect` (select) entities:
|
||||
|
||||
| Effect Name | Description |
|
||||
| :--- | :--- |
|
||||
| **Off** | All LEDs are turned off. |
|
||||
| **Solid** | All LEDs display a single, constant color. |
|
||||
| **Slow Pulse** | LEDs slowly fade in and out. |
|
||||
| **Medium Pulse** | LEDs fade in and out at a moderate speed. |
|
||||
| **Fast Pulse** | LEDs rapidly fade in and out. |
|
||||
| **Slow Blink** | LEDs turn on and off slowly. |
|
||||
| **Medium Blink** | LEDs turn on and off at a moderate speed. |
|
||||
| **Fast Blink** | LEDs rapidly turn on and off. |
|
||||
| **Spin** | A single LED "spins" around the strip. |
|
||||
|
||||
**Edit LVA config.json file:**
|
||||
|
||||
```bash
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
**Add MQTT configuration entries to LVA config.json file:**
|
||||
|
||||
```bash
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
}
|
||||
```
|
||||
|
||||
**Example of complete config.json file**
|
||||
***Note: Change the MQTT values to match your system!***
|
||||
|
||||
```bash
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Enable & start:**
|
||||
```bash
|
||||
systemctl --user restart linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Optional (Grove Port LEDs)</strong></summary>
|
||||
|
||||
This optional configuration support the use of the ReSpeaker 2Mic Grove Port with APA102 LEDs.
|
||||
|
||||
| Grove Pigtail | Function (on ReSpeaker Hat) | Solder to LED Strip |
|
||||
| :--- | :--- | :--- |
|
||||
| ⚫ **Black Wire** | Ground (GND) | **GND** (Ground) |
|
||||
| 🔴 **Red Wire** | Power (VCC) | **VCC / 5V** (Power) |
|
||||
| 🟡 **Yellow Wire** | GPIO12 (Signal 1) | **DI** (Data Input) |
|
||||
| ⚪ **White Wire** | GPIO13 (Signal 2) | **CI** (Clock Input) |
|
||||
|
||||
**Edit LVA config.json file:**
|
||||
|
||||
```bash
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
**Add Grove (GPIO) configuration entries to LVA config.json file:**
|
||||
|
||||
```bash
|
||||
"led": {
|
||||
"led_type": "dotstar",
|
||||
"interface": "gpio",
|
||||
"clock_pin": 13,
|
||||
"data_pin": 12,
|
||||
"num_leds": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Example (LVA config.json file with MQTT and Grove Port enabled)**
|
||||
***Note: Change the GPIO values to match your system!***
|
||||
|
||||
```bash
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
},
|
||||
"led": {
|
||||
"led_type": "dotstar",
|
||||
"interface": "gpio",
|
||||
"clock_pin": 13,
|
||||
"data_pin": 12,
|
||||
"num_leds": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
**Enable & start:**
|
||||
```bash
|
||||
systemctl --user restart linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Optional (Acoustic Echo Cancellation) </strong></summary>
|
||||
|
||||
This optional configuration support the use AEC and require either a working PipeWire-Pulse or PulseAudio backend.
|
||||
|
||||
Enable the echo cancel PulseAudio module:
|
||||
|
||||
```bash
|
||||
pactl load-module module-echo-cancel \
|
||||
aec_method=webrtc \
|
||||
aec_args="analog_gain_control=1 digital_gain_control=1 noise_suppression=1"
|
||||
```
|
||||
|
||||
If successfully loaded you will see an ID presented after the command. Keep this ID as you may need it to "tune" the AEC.
|
||||
|
||||
```bash
|
||||
pactl load-module module-echo-cancel \
|
||||
aec_method=webrtc \
|
||||
aec_args="analog_gain_control=1 digital_gain_control=1 noise_suppression=1"
|
||||
536870916
|
||||
```
|
||||
|
||||
Determine the names of the echo cancellation audio devices. On my system it is "Echo-Cancel Source" and "pipewire/echo-cancel-sink":
|
||||
|
||||
```bash
|
||||
~/linux-voice-assistant/script/run --list-input-devices
|
||||
Input devices
|
||||
=============
|
||||
[0] Built-in Audio Stereo
|
||||
[1] Echo-Cancel Source
|
||||
```
|
||||
|
||||
```bash
|
||||
~/linux-voice-assistant/script/run --list-output-devices
|
||||
Output devices
|
||||
==============
|
||||
auto: Autoselect device
|
||||
pipewire: Default (pipewire)
|
||||
pipewire/alsa_output.platform-soc_sound.stereo-fallback: Built-in Audio Stereo
|
||||
pipewire/echo-cancel-sink: Echo-Cancel Sink
|
||||
pulse/alsa_output.platform-soc_sound.stereo-fallback: Built-in Audio Stereo
|
||||
pulse/echo-cancel-sink: Echo-Cancel Sink
|
||||
alsa: Default (alsa)
|
||||
alsa/sysdefault: Default Audio Device
|
||||
alsa/lavrate: Rate Converter Plugin Using Libav/FFmpeg Library
|
||||
alsa/samplerate: Rate Converter Plugin Using Samplerate Library
|
||||
alsa/speexrate: Rate Converter Plugin Using Speex Resampler
|
||||
alsa/jack: JACK Audio Connection Kit
|
||||
alsa/oss: Open Sound System
|
||||
alsa/pipewire: PipeWire Sound Server
|
||||
alsa/speex: Plugin using Speex DSP (resample, agc, denoise, echo, dereverb)
|
||||
alsa/upmix: Plugin for channel upmix (4,6,8)
|
||||
alsa/vdownmix: Plugin for channel downmix (stereo) with a simple spacialization
|
||||
alsa/playback: playback
|
||||
alsa/capture: capture
|
||||
alsa/dmixed: dmixed
|
||||
alsa/array: array
|
||||
alsa/plughw:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/Hardware device with all software conversions
|
||||
alsa/sysdefault:CARD=vc4hdmi: vc4-hdmi, MAI PCM i2s-hifi-0/Default Audio Device
|
||||
alsa/hdmi:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/HDMI Audio Output
|
||||
alsa/dmix:CARD=vc4hdmi,DEV=0: vc4-hdmi, MAI PCM i2s-hifi-0/Direct sample mixing device
|
||||
alsa/usbstream:CARD=vc4hdmi: vc4-hdmi/USB Stream Output
|
||||
alsa/plughw:CARD=seeed2micvoicec,DEV=0: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Hardware device with all software conversions
|
||||
alsa/sysdefault:CARD=seeed2micvoicec: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Default Audio Device
|
||||
alsa/dmix:CARD=seeed2micvoicec,DEV=0: seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0/Direct sample mixing device
|
||||
alsa/usbstream:CARD=seeed2micvoicec: seeed-2mic-voicecard/USB Stream Output
|
||||
jack: Default (jack)
|
||||
sdl: Default (sdl)
|
||||
```
|
||||
|
||||
**Edit LVA config.json file:**
|
||||
|
||||
```bash
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
**Add AEC configuration entries to LVA config.json file:**
|
||||
|
||||
```bash
|
||||
"audio": {
|
||||
"input_device": "Echo-Cancel Source",
|
||||
"input_block_size": 1024,
|
||||
"output_device": "pipewire/echo-cancel-sink"
|
||||
}
|
||||
```
|
||||
|
||||
**Example (LVA config.json file with MQTT, Grove Port, and AEC enabled)**
|
||||
***Note: Change the source and sink values to match your system!***
|
||||
|
||||
```bash
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
},
|
||||
"led": {
|
||||
"led_type": "dotstar",
|
||||
"interface": "gpio",
|
||||
"clock_pin": 13,
|
||||
"data_pin": 12,
|
||||
"num_leds": 10
|
||||
},
|
||||
"audio": {
|
||||
"input_device": "Echo-Cancel Source",
|
||||
"input_block_size": 1024,
|
||||
"output_device": "pipewire/echo-cancel-sink"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**AEP Tuning Options (aec_args)**
|
||||
|
||||
In order to modify the echo cancellation device setting, you must first remove the previously installed modules.
|
||||
|
||||
Remove AEP module (use the module ID listed when AEP module was loaded):
|
||||
|
||||
```bash
|
||||
pactl unload-module 536870916
|
||||
```
|
||||
|
||||
AEP tuning settings:
|
||||
|
||||
| Setting | Allowed Values | Typical Value | What It Does | When To Change |
|
||||
|------------------------|----------------|---------------|-------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `analog_gain_control` | `0` or `1` | `0` | Lets WebRTC AEC “ride” the hardware/analog mic gain. | Leave `0` when you already tuned mic gain in ALSA/Pulse. Use `1` only if your mic is too quiet and you want auto-leveling at the expense of some consistency. |
|
||||
| `digital_gain_control` | `0` or `1` | `1` | Software AGC on the captured signal (after the ADC). | Keep `1` for voice assistants so wake-word and STT get a stable level. Turn `0` if you already run separate AGC or notice pumping/breathing. |
|
||||
| `noise_suppression` | `0` or `1` | `1` | Enables WebRTC noise reduction on the mic signal. | Keep `1` in most cases (fans, room noise). Try `0` if audio sounds “underwater” or dull and your environment is already very quiet. |
|
||||
| `extended_filter`* | `0` or `1` | `1` (often) | Uses a more robust AEC filter that handles tricky echo paths / long delays. | Use `1` for speaker-in-room setups (like LVA) unless CPU is extremely constrained. |
|
||||
| `delay_agnostic`* | `0` or `1` | `1` (often) | Makes AEC less sensitive to exact playback/capture latency. | Keep `1` if devices/paths change or Bluetooth is involved. Set `0` only if you know latency is rock-stable and want to shave a bit of CPU. |
|
||||
| `drift_compensation`* | `0` or `1` | `1` (often) | Compensates for clock drift between capture and playback devices. | Use `1` if mic and speakers are on different hardware (USB mic + HDMI/Bluetooth out). `0` is OK when both share the same clock (onboard codec only). |
|
||||
| `voice_detection`* | `0` or `1` | `0` or `1` | Simple VAD that can help AEC and noise suppression focus on speech segments. | Try `1` if you see good wake-word hits but noisy STT. Use `0` if it seems to cut off very quiet speech or initial phonemes. |
|
||||
|
||||
|
||||
**Enable & start:**
|
||||
```bash
|
||||
systemctl --user restart linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Optional (GPIO Button)</strong></summary>
|
||||
|
||||
Add support for the ReSpeaker 2-Mic HAT momentary button as a first-class control surface for the Linux Voice Assistant. The button now behaves like:
|
||||
|
||||
***Short press***
|
||||
|
||||
If TTS or music is playing → stop playback (equivalent to the Stop wake word)
|
||||
|
||||
Otherwise → start a new conversation (equivalent to a wake word trigger)
|
||||
|
||||
***Long press***
|
||||
|
||||
Toggle microphone mute (wired through the existing set_mic_mute event, so MQTT state and LEDs stay in sync)
|
||||
|
||||
The implementation uses a polling-based GPIO loop (RPi.GPIO) instead of kernel edge-detection to avoid “Failed to add edge detection” issues on some HAT/overlay setups. Button behavior is fully configurable via config.json.
|
||||
|
||||
**Requires a compatible GPIO board** This has been tested on the ReSpeaker 2-Mic Pi Hat:
|
||||
|
||||
**Edit the LVA config.json file:**
|
||||
|
||||
```bash
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
**Enable the GPIO button support:**
|
||||
|
||||
```bash
|
||||
"button": {
|
||||
"enabled": true,
|
||||
"pin": 17,
|
||||
"long_press_seconds": 1.0
|
||||
}
|
||||
```
|
||||
|
||||
**Example (LVA config.json file with MQTT, Grove Port, and GPIO Button enabled** ***Note: The GPIO button can be changed from the default (17) on the 2-Mic hat.***
|
||||
|
||||
```bash
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_username",
|
||||
"password": "mqtt_password"
|
||||
},
|
||||
"led": {
|
||||
"led_type": "dotstar",
|
||||
"interface": "gpio",
|
||||
"clock_pin": 13,
|
||||
"data_pin": 12,
|
||||
"num_leds": 10
|
||||
},
|
||||
"button": {
|
||||
"enabled": true,
|
||||
"pin": 17,
|
||||
"long_press_seconds": 1.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Enable & start:**
|
||||
|
||||
```bash
|
||||
systemctl --user restart linux-voice-assistant.service
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
</details>
|
||||
|
||||
## 6. 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 is displayed on the registration page. Default is "OK Nabu".
|
||||
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- Use "journalctl --user -u linux-voice-assistant.service -f" to check for errors. 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
|
||||
|
||||
|
||||
## 8. 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 Nabu **(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 ~/linux-voice-assistant/wakewords/openWakeWord directory. If a model is currupted, the LVA will fail to start.
|
||||
Each model added will need a corresponding json file. (note the json file names matches the tflite name)
|
||||
|
||||
***Example***
|
||||
|
||||
Add Model:
|
||||
```
|
||||
/linux-voice-assistant/wakewords/openWakeWord/hal_v2.tflite
|
||||
```
|
||||
Create Json:
|
||||
```
|
||||
/linux-voice-assistant/wakewords/openWakeWord/hal_v2.json
|
||||
```
|
||||
|
||||
Contents:
|
||||
```
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "HAL",
|
||||
"model": "hal_v2.tflite"
|
||||
}
|
||||
```
|
||||
|
||||
**Word of warning. I have had problems with some of the community provided wake words. YMMV**
|
||||
|
||||
|
||||
## 9. Switching between ALSA, PW, or PA see section 5.
|
||||
|
||||
If you intend to switch from PA or PW to ALSA, you must first stop and disable the corresponding user-mode services.
|
||||
|
||||
###PulseAudio
|
||||
|
||||
```bash
|
||||
sudo systemctl --user stop pulseaudio.service
|
||||
```
|
||||
```bash
|
||||
sudo systemctl --user disable pulseaudio.service
|
||||
```
|
||||
###PipeWire
|
||||
|
||||
```bash
|
||||
sudo systemctl --user stop pipewire.service
|
||||
```
|
||||
```bash
|
||||
sudo systemctl --user disable pipewire.service
|
||||
```
|
||||
@@ -0,0 +1,223 @@
|
||||
# XVF3800 Mute Button & USB Control Integration
|
||||
|
||||
This document explains how the **ReSpeaker XVF3800 4‑Mic USB Array** integrates its
|
||||
built‑in mute button and red mute LED with the **Linux Voice Assistant (LVA)**.
|
||||
|
||||
The goal is:
|
||||
- Pressing the XVF3800’s hardware mute button updates the LVA mic state.
|
||||
- Muting/unmuting the LVA (via Home Assistant, MQTT, or other inputs) updates
|
||||
the XVF3800’s mic mute circuit and red LED.
|
||||
- No extra binaries (like the vendor `xvf_host`) are required — LVA talks to
|
||||
the XVF3800 directly via **USB control transfers** using `pyusb`.
|
||||
|
||||
---
|
||||
|
||||
## 1. How it Works (High‑Level)
|
||||
|
||||
The XVF3800 exposes a vendor control protocol over USB that can read and write
|
||||
internal GPIO pins (GPO). One of these pins drives both:
|
||||
|
||||
- the microphone mute circuit
|
||||
- the red mute LED on the XVF3800 front panel
|
||||
|
||||
LVA’s `XVF3800ButtonController` does three things:
|
||||
|
||||
1. **Reads hardware mute state periodically**
|
||||
It polls the XVF3800’s GPO state (pin X0D30) in a background thread using
|
||||
`pyusb`. If the value changes, LVA treats that as “the user pressed the
|
||||
XVF3800 mute button” and publishes a `set_mic_mute` event.
|
||||
|
||||
2. **Mirrors LVA mute → XVF3800**
|
||||
It subscribes to `mic_muted` / `mic_unmuted` events. Whenever LVA’s mute
|
||||
state changes (for example, via MQTT, HA UI, or a GPIO button), the
|
||||
controller writes the new state back to X0D30 so that the mic mute circuit
|
||||
and red LED stay in sync.
|
||||
|
||||
3. **Initial sync on startup**
|
||||
On first successful read, the controller treats the *hardware* mute state
|
||||
as the source of truth and publishes `set_mic_mute` so LVA starts aligned
|
||||
with whatever the XVF3800 is currently doing.
|
||||
|
||||
No PulseAudio/PipeWire configuration is required for this feature; it only
|
||||
depends on **USB access to the XVF3800** and the `pyusb` library.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- You are running a branch of **linux‑voice‑assistant** that includes:
|
||||
- `linux_voice_assistant/xvf3800_button_controller.py`
|
||||
- The updated `__main__.py` and `config.py` wiring for `XVF3800ButtonController`
|
||||
- The ReSpeaker XVF3800 is connected over USB and appears in `lsusb` as:
|
||||
- `2886:001a Seeed Technology Co., Ltd. reSpeaker XVF3800 4-Mic Array`
|
||||
- The LVA `.venv` includes `pyusb` (already present in `pyproject.toml`):
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pip install pyusb
|
||||
```
|
||||
|
||||
On most systems, user‑mode processes can talk to the XVF3800 over USB without
|
||||
`sudo` once the device is enumerated. If you see `USBError: [Errno 13] Access denied`,
|
||||
you may need a udev rule — that’s outside this document but worth noting.
|
||||
|
||||
---
|
||||
|
||||
## 3. Enabling the XVF3800 Mute Integration
|
||||
|
||||
The integration is controlled via the `button` section of `config.json`.
|
||||
Two “modes” are supported:
|
||||
|
||||
- `"gpio"` – legacy Raspberry Pi GPIO button (ReSpeaker 2‑Mic HAT, etc.)
|
||||
- `"xvf3800"` – USB‑based mute integration for the ReSpeaker XVF3800
|
||||
|
||||
### 3.1 Example `config.json` for XVF3800
|
||||
|
||||
Edit your LVA configuration file (usually `linux_voice_assistant/config.json`)
|
||||
and add or adjust the `button` block like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
|
||||
// ... other sections (audio, wake_word, esphome, led, mqtt, etc.) ...
|
||||
|
||||
"button": {
|
||||
"enabled": true,
|
||||
"mode": "xvf3800",
|
||||
// Optional: how often to poll the XVF3800 GPO state (seconds)
|
||||
"poll_interval_seconds": 0.05
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `"enabled": true` turns on button support in general.
|
||||
- `"mode": "xvf3800"` switches from the GPIO‑based button controller to the
|
||||
USB‑based XVF3800 controller.
|
||||
- `poll_interval_seconds` is optional and defaults to `0.01` if omitted. Values
|
||||
between 0.02 and 0.1 are typically fine.
|
||||
|
||||
The GPIO‑specific fields (`pin`, `long_press_seconds`) are ignored in
|
||||
`xvf3800` mode — the XVF3800’s onboard mute button is treated as a
|
||||
**dedicated mute toggle only**, not a wake/stop button.
|
||||
|
||||
---
|
||||
|
||||
## 4. Runtime Behaviour
|
||||
|
||||
Once enabled and the LVA service is restarted, the mute integration behaves as follows:
|
||||
|
||||
### 4.1 Pressing the XVF3800 Mute Button
|
||||
|
||||
1. The XVF3800 firmware changes the GPO state of pin X0D30 and lights or clears
|
||||
the red mute LED.
|
||||
2. The `XVF3800ButtonController` sees the changed GPO values the next time it
|
||||
polls USB.
|
||||
3. It publishes a `set_mic_mute` event on the LVA `EventBus` with the new state.
|
||||
4. The `MicMuteHandler`:
|
||||
- Updates `ServerState.mic_muted` and `mic_muted_event` (pausing/resuming the
|
||||
audio capture thread).
|
||||
- Publishes `mic_muted` / `mic_unmuted` events used by other components
|
||||
(LED controller, MQTT, etc.).
|
||||
- Notifies the MQTT controller so the HA “Mute Microphone” entity stays in sync.
|
||||
|
||||
From the user’s perspective:
|
||||
|
||||
- The red LED on the XVF3800 tracks mic mute.
|
||||
- The HA “Mute Microphone” switch also tracks the hardware button.
|
||||
- The audio engine actually stops capturing when muted.
|
||||
|
||||
### 4.2 Toggling Mute from Home Assistant / MQTT / Other Inputs
|
||||
|
||||
1. Home Assistant toggles the LVA “Mute Microphone” switch (or another input
|
||||
publishes a `set_mic_mute` event).
|
||||
2. The `MicMuteHandler` applies the new mute state, as usual.
|
||||
3. It emits a `mic_muted` or `mic_unmuted` event.
|
||||
4. The `XVF3800ButtonController` receives this event and writes the new state
|
||||
back to X0D30 via USB.
|
||||
5. The XVF3800’s mic mute circuit and red LED update to match LVA.
|
||||
|
||||
This keeps **hardware**, **LVA state**, and **Home Assistant UI** aligned.
|
||||
|
||||
---
|
||||
|
||||
## 5. Coexistence with Other Buttons
|
||||
|
||||
The XVF3800 mute integration is intentionally narrow in scope:
|
||||
|
||||
- It does **not** implement short vs. long press behaviour.
|
||||
- It does **not** trigger wake‑word, stop‑word, or playback controls.
|
||||
- It is purely “mute on / mute off” with bidirectional sync.
|
||||
|
||||
If you also have a GPIO button (e.g. a ReSpeaker 2‑Mic HAT momentary switch)
|
||||
wired for wake/stop behaviour, you can:
|
||||
|
||||
- Leave `button.mode: "gpio"` and **not** use the XVF3800 mute integration, or
|
||||
- Move wake/stop to the GPIO button controller and leave the XVF3800 button
|
||||
as mute‑only by setting `mode: "xvf3800"` and handling wake/stop elsewhere.
|
||||
|
||||
At the moment, `button.mode` is **single‑valued** — it selects either the
|
||||
GPIO controller or the XVF3800 controller, not both at once. If you ever want
|
||||
to support both concurrently, that would be a separate enhancement (e.g. a
|
||||
list of button backends).
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
### 6.1 No USB Access / Permission Errors
|
||||
|
||||
Symptoms in the logs (journalctl or console):
|
||||
|
||||
```text
|
||||
Failed to initialize XVF3800 USB client; mute button integration will be disabled
|
||||
USBError: [Errno 13] Access denied (insufficient permissions)
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `lsusb` shows `2886:001a` while LVA is running.
|
||||
- The LVA service is running as the user who owns the `.venv` and has
|
||||
access to USB devices.
|
||||
- If necessary, add a udev rule that grants access to `idVendor=0x2886`
|
||||
and `idProduct=0x001a` for your LVA user, then reload udev and
|
||||
replug the device.
|
||||
|
||||
The controller will keep retrying USB initialization; once permissions are
|
||||
fixed, it should connect without restarting LVA.
|
||||
|
||||
### 6.2 Mute Button Works but HA Entity Doesn’t Update
|
||||
|
||||
- Verify MQTT is enabled and configured correctly in `config.json`.
|
||||
- Confirm that the HA “Mute Microphone” entity exists and belongs to the
|
||||
correct LVA device.
|
||||
- Check logs for `MicMuteHandler` and `MqttController` messages when pressing
|
||||
the XVF3800 mute button.
|
||||
|
||||
### 6.3 HA Mute Switch Works but XVF3800 LED Doesn’t Change
|
||||
|
||||
- Ensure `button.enabled` is `true` and `"mode": "xvf3800"` in `config.json`.
|
||||
- Look for log lines like:
|
||||
- `Initializing XVF3800ButtonController (mode=xvf3800)`
|
||||
- `Connected to ReSpeaker XVF3800 for mute control`
|
||||
- If you see only GPIO logs, confirm that the branch you’re running includes
|
||||
`xvf3800_button_controller.py` and the updated `__main__.py` wiring.
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
With `mode: "xvf3800"`, the Linux Voice Assistant treats the ReSpeaker
|
||||
XVF3800’s onboard mute button and red LED as a **first‑class mute control**:
|
||||
|
||||
- Pressing the XVF3800 mute button updates LVA and Home Assistant.
|
||||
- Muting via Home Assistant or other inputs updates the XVF3800 hardware.
|
||||
- The audio engine, LEDs, and MQTT entities stay in sync with the device’s
|
||||
actual hardware mute state.
|
||||
|
||||
This integration is fully optional and coexists with existing audio and LED
|
||||
behaviour — it simply adds a reliable, hardware‑based mute path for the
|
||||
XVF3800‑based satellites.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Linux Voice Assistant with ReSpeaker XVF3800 4‑Mic Array — Installation & Configuration Guide
|
||||
|
||||
> Drafted with ChatGPT based on existing `linux-voice-assistant-2mic-install.md` and community test results from the XVF3800 integration Discussion.
|
||||
|
||||
> Special thanks to @JenniferHatches and @cknic for making the recommendation and doing so much work already for support of the reSpeaker XVF3800. (https://github.com/cknic/linux-voice-assistant) The addition of XVF3800 support would never have happened without their troubleshooting of the code and fantastic suggestions when working through bugs.
|
||||
|
||||
This guide shows how to use the **Seeed ReSpeaker XVF3800 USB 4‑Mic Array** with the **Linux Voice Assistant** (LVA) project on Raspberry Pi or other Linux hosts.
|
||||
|
||||
Unlike the ReSpeaker 2‑Mic HAT, the XVF3800 is a **USB Audio Class 2.0** device: it acts as a “smart microphone + speaker” front end with built‑in **AEC, beamforming, noise suppression, AGC, and echo‑canceling speakerphone** processing.
|
||||
|
||||
No custom kernel drivers are required. Plug it in, setup the device in LVA’s `config.json`, and it will “just work” as a high‑quality mic and speaker (if connected).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Linux host (e.g., Raspberry Pi 4/5) running one of:
|
||||
- Raspberry Pi OS (Bookworm or Trixie)
|
||||
- Debian 13 (trixie) or similar
|
||||
- Other modern Linux with PipeWire or PulseAudio
|
||||
- Python 3.11+ recommended
|
||||
- **ReSpeaker XVF3800 4‑Mic Array**, connected via its **XMOS USB‑C port**
|
||||
- SSH access to your LVA instance
|
||||
- Network access to the Home Assistant for registration
|
||||
|
||||
## Recommended Configuration
|
||||
|
||||
- To use the advanced LED effects, upgrade to the latest XVF3800 firmware (respeaker_xvf3800_usb_dfu_firmware_v2.0.7.bin as of this writing).
|
||||
-- https://github.com/respeaker/reSpeaker_XVF3800_USB_4MIC_ARRAY/blob/master/xmos_firmwares/dfu_guide.md
|
||||
- Tested and works on a Raspberry Pi Zero 2W, but I don't recommend attempting to flash the firmware with this device.
|
||||
- PipeWire (or PulseAudio) installed and tested. I haven't tested the XVF3800 with ALSA.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install system packages and Pipewire (Recommended)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade
|
||||
sudo apt install build-essential git \
|
||||
libmpv-dev mpv python3-dev python3-venv \
|
||||
pulseaudio-utils pipewire wireplumber \
|
||||
pipewire-audio libspa-0.2-modules libusb-1.0-0 \
|
||||
dbus-user-session
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Get the code (XVF3800 branch)
|
||||
|
||||
If you don’t already have the project cloned:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/imonlinux/linux-voice-assistant.git
|
||||
cd linux-voice-assistant
|
||||
```
|
||||
|
||||
If you already have the repository, you can:
|
||||
|
||||
```bash
|
||||
cp ~/linux-voice-assistant/linux_voice_assistant/config.json ~/config.json
|
||||
cd ~/linux-voice-assistant
|
||||
git pull
|
||||
cp ~/config.json ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
Implement the UDEV rule to prevent the XVF3800 from taking naps and to allow user access to the device
|
||||
|
||||
```bash
|
||||
sudo cp ~/linux-voice-assistant/XVF3800/99-respeaker-xvf3800.rules /etc/udev/rules.d/
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
|
||||
Add user to the plugdev group for access to the XVF3800 and reboot
|
||||
|
||||
> Tip: If you use a different username than `pi`, adjust user accordingly.
|
||||
|
||||
```bash
|
||||
sudo usermod -aG plugdev pi
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Set up the project (creates a virtualenv, installs dependencies, etc.):
|
||||
|
||||
```bash
|
||||
cd ~/linux-voice-assistant
|
||||
script/setup
|
||||
```
|
||||
|
||||
1. Connect the XVF3800 to your Pi/host using the **XMOS USB‑C port** (the one next to the 3.5 mm audio jack).
|
||||
2. If you want local audio output, connect the XVF3800’s headphone jack or JST-PH 2.0mm 2 Pin to an external speaker.
|
||||
|
||||
---
|
||||
|
||||
## 3. Update the config.json file for XVF3800 support and options
|
||||
|
||||
Edit the config.json file:
|
||||
|
||||
```bash
|
||||
nano ~/linux-voice-assistant/linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
Add or update the following sections (Recommended settings):
|
||||
|
||||
> Tip: Be sure to change the name if you have more than one LVA!
|
||||
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
},
|
||||
"led": {
|
||||
"enabled": true,
|
||||
"led_type": "xvf3800",
|
||||
"interface": "usb"
|
||||
},
|
||||
"button": {
|
||||
"enabled": true,
|
||||
"mode": "xvf3800",
|
||||
"poll_interval_seconds": 0.15
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt_server",
|
||||
"password": "mqtt_password"
|
||||
},
|
||||
"audio": {
|
||||
"volume_sync": true,
|
||||
"max_volume_percent": 150
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Take a look at ~/linux-voice-assistant/linux_voice_assistant/config.json.example for details on these settings as well as all available options.
|
||||
|
||||
---
|
||||
|
||||
## 4. Start LVA as a user-mode service
|
||||
|
||||
The service setup is identical to other configurations (2‑Mic, etc.). Assuming you want
|
||||
LVA to run as a user‑mode systemd service:
|
||||
|
||||
Enable linger for your user (e.g. `pi`)
|
||||
|
||||
```bash
|
||||
sudo loginctl enable-linger pi
|
||||
```
|
||||
|
||||
Install the systemd user service
|
||||
|
||||
> Tip: If you use a different username than `pi`, adjust user accordingly.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp ~/linux-voice-assistant/service/linux-voice-assistant_xvf3800.service ~/.config/systemd/user/linux-voice-assistant.service
|
||||
```
|
||||
|
||||
Enable & start
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now linux-voice-assistant.service
|
||||
```
|
||||
|
||||
> Note: The LVA initiate a XVF3800 reboot at start, this is needed to resolve a "mic not detecting audio" issue. As a result, the LVA will take a little longer to start up and be ready.
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
systemctl --user status linux-voice-assistant --no-pager -l
|
||||
```
|
||||
|
||||
Look for log lines indicating:
|
||||
|
||||
- LVA connected to Home Assistant
|
||||
- Wake‑word engine initialized
|
||||
- Satellite streaming started on wake word
|
||||
|
||||
---
|
||||
|
||||
## 5. Acoustic Echo Cancellation (AEC)
|
||||
|
||||
The XVF3800 already implements **hardware AEC / echo‑canceling speakerphone** processing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting & debug tips
|
||||
|
||||
### Check that LVA sees the XVF3800
|
||||
|
||||
Make sure the XVF3800 is available to the LVA code:
|
||||
|
||||
```bash
|
||||
cd ~/linux-voice-assistant
|
||||
script/run --list-input-devices
|
||||
script/run --list-output-devices
|
||||
```
|
||||
|
||||
If `reSpeaker XVF3800 4-Mic Array Analog Stereo` doesn’t appear:
|
||||
|
||||
- Check `arecord -l` / `aplay -l` again.
|
||||
- Ensure the XVF3800 is connected to the XMOS USB‑C port and powered.
|
||||
- Check `lsusb` for a device with `2886:001a`:
|
||||
|
||||
```bash
|
||||
lsusb | grep -i 2886
|
||||
```
|
||||
|
||||
### Tail the LVA logs
|
||||
|
||||
```bash
|
||||
journalctl --user -u linux-voice-assistant.service -f
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- “Audio engine started.”
|
||||
- “Opening audio input device: reSpeaker XVF3800 4-Mic Array Analog Stereo”
|
||||
- Wake‑word detection lines (MicroWakeWord / OpenWakeWord)
|
||||
@@ -0,0 +1,163 @@
|
||||
# Want to run LVA on a Linux Graphical Desktop?
|
||||
|
||||
### 1. Install the dependancies:
|
||||
|
||||
### Debian based distro:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install \
|
||||
libmpv-dev git python3-dev mpv
|
||||
```
|
||||
|
||||
### Fedora based distro:
|
||||
|
||||
```bash
|
||||
sudo dnf install \
|
||||
mpv-devel python3-devel git mpv
|
||||
```
|
||||
|
||||
### Arch based distro:
|
||||
|
||||
```bash
|
||||
sudo pacman -Syu \
|
||||
python-pip python-virtualenv mpv
|
||||
```
|
||||
|
||||
### 2. Open Firewall for LVA:
|
||||
|
||||
### UFW:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 6053/udp
|
||||
sudo ufw allow 6053/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
### Firewall-CMD:
|
||||
|
||||
```bash
|
||||
sudo firewall-cmd --permanent --zone=public --add-port=6053/tcp
|
||||
sudo firewall-cmd --permanent --zone=public --add-port=6053/udp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 3. Get the code:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/imonlinux/linux-voice-assistant.git
|
||||
```
|
||||
|
||||
### 4. Setup Linux Voice Assistant (LVA):
|
||||
|
||||
```bash
|
||||
cd linux-voice-assistant/
|
||||
chmod +x script/tray
|
||||
script/setup
|
||||
|
||||
source .venv/bin/activate
|
||||
pip install -e .[tray]
|
||||
deactivate
|
||||
```
|
||||
|
||||
### 5. Modify config.json File:
|
||||
|
||||
```bash
|
||||
nano linux_voice_assistant/config.json
|
||||
```
|
||||
|
||||
Change the LVA name and add the details for MQTT (MQTT is required for the LVA Tray Client)
|
||||
***Update the MQTT details to match your system!***
|
||||
|
||||
```bash
|
||||
{
|
||||
"app": {
|
||||
"name": "Debian Voice Assistant"
|
||||
},
|
||||
"mqtt": {
|
||||
"host": "192.168.1.2",
|
||||
"port": 1883,
|
||||
"username": "mqtt-user",
|
||||
"password": "mqtt-password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Create LVA User Mode Systemd Unit file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
cp service/linux-voice-assistant.service ~/.config/systemd/user/linux-voice-assistant.service
|
||||
|
||||
```
|
||||
|
||||
### Edit the LVA Unit file for correct path:
|
||||
|
||||
```bash
|
||||
systemctl --user edit --force --full linux-voice-assistant.service
|
||||
```
|
||||
### Enter the correct path for WorkingDirectory and ExecStart
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=Linux Voice Assistant
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
ExecStart=/home/pi/linux-voice-assistant/script/run
|
||||
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
### 7. Create the LVA Tray Client User Mode Systemd Unit file:
|
||||
|
||||
```bash
|
||||
cp service/linux-voice-assistant-tray.service ~/.config/systemd/user/linux-voice-assistant-tray.service
|
||||
```
|
||||
|
||||
### Edit the LVA Tray Unit file for correct path:
|
||||
|
||||
```bash
|
||||
systemctl --user edit --force --full linux-voice-assistant-tray.service
|
||||
```
|
||||
|
||||
### Enter the correct path for WorkingDirectory and ExecStart
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=Linux Voice Assistant Tray Client
|
||||
# Make sure we have a graphical session
|
||||
After=graphical-session.target
|
||||
Wants=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/james/Workbench/linux-voice-assistant
|
||||
ExecStart=/home/james/Workbench/linux-voice-assistant/script/tray
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
### Start only the LVA Tray Application
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now linux-voice-assistant-tray.service
|
||||
```
|
||||
|
||||
This will add an icon to your taskbar. Click on the icon and select "Start LVA".
|
||||
|
||||
Follow the instructions in HA to register the LVA.
|
||||
|
||||
Use the MQTT device that is created in HA to adjust the icon color for each of the LVA states.
|
||||
@@ -0,0 +1,45 @@
|
||||
# XVF3800 Legacy LED Effects Mapping for Linux Voice Assistant
|
||||
|
||||
This document maps the **legacy ReSpeaker XVF3800 LED_EFFECT modes** to the higher-level LED effects used by the **linux-voice-assistant (LVA)**.
|
||||
|
||||
## XVF3800 Legacy LED_EFFECT Values
|
||||
|
||||
From the Seeed/XMOS documentation:
|
||||
|
||||
- `LED_EFFECT 0` → **off**
|
||||
- `LED_EFFECT 1` → **breath**
|
||||
- `LED_EFFECT 2` → **rainbow**
|
||||
- `LED_EFFECT 3` → **single color**
|
||||
- `LED_EFFECT 4` → **DoA mode**
|
||||
|
||||
## LVA Effects → XVF3800 Mapping
|
||||
|
||||
LVA exposes higher-level effects such as **Off / Solid / Slow/Medium/Fast Pulse / Slow/Medium/Fast Blink / Spin**.
|
||||
On XVF3800 (legacy firmware), these are currently mapped onto the limited built-in modes as follows:
|
||||
|
||||
| LVA Effect Name | XVF3800 `LED_EFFECT` | Additional Parameters | Behavior on XVF3800 (Legacy) |
|
||||
|------------------|----------------------|--------------------------------------|-------------------------------------------------------------------------|
|
||||
| `off` | `0` (off) | – | LED ring completely off. |
|
||||
| `solid` | `3` (single color) | `LED_COLOR`, `LED_BRIGHTNESS` | Full ring in a single static color. |
|
||||
| `slow_pulse` | `1` (breath) | `LED_SPEED` = slow | Slow breathing in the configured color. |
|
||||
| `medium_pulse` | `1` (breath) | `LED_SPEED` = medium | Medium breathing in the configured color. |
|
||||
| `fast_pulse` | `1` (breath) | `LED_SPEED` = fast | Fast breathing in the configured color. |
|
||||
| `slow_blink` | `1` (breath) * | `LED_SPEED` = slow | Approximated with slow breath (no true sharp on/off blink). |
|
||||
| `medium_blink` | `1` (breath) * | `LED_SPEED` = medium | Approximated with medium breath. |
|
||||
| `fast_blink` | `1` (breath) * | `LED_SPEED` = fast | Approximated with fast breath. |
|
||||
| `spin` | `3` or `1` ** | Typically treated as solid/pulsed | No native “spin”; currently falls back to a static or pulsed behavior. |
|
||||
|
||||
\* There is no dedicated “blink” primitive in the legacy API, so all blink variants are currently rendered as **breathing** with different speeds. They will look like fading rather than a true square‑wave blink until we add a more advanced mapping or per‑LED pattern logic.
|
||||
|
||||
\** The legacy firmware does not offer a true “spin” animation. For now, `spin` is effectively treated as either **single color** or a **breath** variant to keep behavior predictable.
|
||||
|
||||
## Practical Notes
|
||||
|
||||
- Under the legacy backend, **only three real modes** are used:
|
||||
- `0` → off
|
||||
- `1` → breath (with speed control)
|
||||
- `3` → single color
|
||||
- All the LVA **pulse** and **blink** variants are essentially **different speeds of `LED_EFFECT=1`**.
|
||||
- The **spin** effect is not yet a genuine rotating animation on XVF3800; it’s a placeholder mapped onto existing firmware modes.
|
||||
|
||||
As we move toward **firmware v2.0.7+** and `LED_RING_COLOR` support, we’ll be able to implement richer, per‑LED patterns (including real spin and more accurate blink behaviors) while keeping the same high-level LVA effect names.
|
||||
@@ -1 +0,0 @@
|
||||
https://github.com/tphakala/tflite_c/
|
||||
Binary file not shown.
Binary file not shown.
+540
-429
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, List, Optional
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi._frame_helper.packets import make_plain_text_packets
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
ConnectRequest,
|
||||
ConnectResponse,
|
||||
AuthenticationRequest, # <-- MODIFIED
|
||||
AuthenticationResponse, # <-- MODIFIED
|
||||
DisconnectRequest,
|
||||
DisconnectResponse,
|
||||
HelloRequest,
|
||||
@@ -57,8 +57,8 @@ class APIServer(asyncio.Protocol):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(msg_inst, ConnectRequest):
|
||||
self.send_messages([ConnectResponse()])
|
||||
if isinstance(msg_inst, AuthenticationRequest): # <-- MODIFIED
|
||||
self.send_messages([AuthenticationResponse()]) # <-- MODIFIED
|
||||
elif isinstance(msg_inst, DisconnectRequest):
|
||||
self.send_messages([DisconnectResponse()])
|
||||
_LOGGER.debug("Disconnect requested")
|
||||
@@ -99,7 +99,6 @@ class APIServer(asyncio.Protocol):
|
||||
|
||||
while self._buffer_len >= 3:
|
||||
self._pos = 0
|
||||
# Read preamble, which should always 0x00
|
||||
if (preamble := self._read_varuint()) != 0x00:
|
||||
_LOGGER.error("Incorrect preamble: %s", preamble)
|
||||
return
|
||||
@@ -113,7 +112,6 @@ class APIServer(asyncio.Protocol):
|
||||
return
|
||||
|
||||
if length == 0:
|
||||
# Empty message (allowed)
|
||||
self._remove_from_buffer()
|
||||
self.process_packet(msg_type, b"")
|
||||
continue
|
||||
@@ -125,7 +123,6 @@ class APIServer(asyncio.Protocol):
|
||||
self.process_packet(msg_type, packet_data)
|
||||
|
||||
def _read(self, length: int) -> bytes | None:
|
||||
"""Read exactly length bytes from the buffer or None if all the bytes are not yet available."""
|
||||
new_pos = self._pos + length
|
||||
if self._buffer_len < new_pos:
|
||||
return None
|
||||
@@ -134,8 +131,6 @@ class APIServer(asyncio.Protocol):
|
||||
if TYPE_CHECKING:
|
||||
assert self._buffer is not None, "Buffer should be set"
|
||||
cstr = self._buffer
|
||||
# Important: we must keep the bounds check (self._buffer_len < new_pos)
|
||||
# above to verify we never try to read past the end of the buffer
|
||||
return cstr[original_pos:new_pos]
|
||||
|
||||
def connection_lost(self, exc):
|
||||
@@ -143,7 +138,6 @@ class APIServer(asyncio.Protocol):
|
||||
self._writelines = None
|
||||
|
||||
def _read_varuint(self) -> int:
|
||||
"""Read a varuint from the buffer or -1 if the buffer runs out of bytes."""
|
||||
if not self._buffer:
|
||||
return -1
|
||||
|
||||
@@ -160,21 +154,12 @@ class APIServer(asyncio.Protocol):
|
||||
return -1
|
||||
|
||||
def _remove_from_buffer(self) -> None:
|
||||
"""Remove data from the buffer."""
|
||||
end_of_frame_pos = self._pos
|
||||
self._buffer_len -= end_of_frame_pos
|
||||
if self._buffer_len == 0:
|
||||
# This is the best case scenario, we can just set the buffer to None
|
||||
# and don't have to copy the data. This is the most common case as well.
|
||||
self._buffer = None
|
||||
return
|
||||
if TYPE_CHECKING:
|
||||
assert self._buffer is not None, "Buffer should be set"
|
||||
# This is the worst case scenario, we have to copy the data
|
||||
# and can't just use the buffer directly. This should only happen
|
||||
# when we read multiple frames at once because the event loop
|
||||
# is blocked and we cannot pull the data out of the buffer fast enough.
|
||||
cstr = self._buffer
|
||||
# Important: we must use the explicit length for the slice
|
||||
# since Cython will stop at any '\0' character if we don't
|
||||
self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Audio processing engine."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import soundcard as sc
|
||||
|
||||
from pymicro_wakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from pyopen_wakeword import OpenWakeWord, OpenWakeWordFeatures
|
||||
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Suppress the "log(0)" warning from pymicro_wakeword
|
||||
warnings.filterwarnings("ignore", category=RuntimeWarning, module="pymicro_wakeword")
|
||||
|
||||
|
||||
class AudioEngine:
|
||||
def __init__(self, state: ServerState, mic, block_size: int):
|
||||
self.state = state
|
||||
self.mic = mic
|
||||
self.block_size = block_size
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# CRITICAL FIX: Add lock for thread-safe wake word reload
|
||||
self._wake_words_lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
"""Starts the audio processing thread."""
|
||||
self._thread = threading.Thread(
|
||||
target=self._process_audio,
|
||||
daemon=True,
|
||||
name="AudioEngineThread",
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stops the audio processing thread."""
|
||||
self.state.shutdown = True
|
||||
# Ensure thread wakes up if waiting
|
||||
self.state.mic_muted_event.set()
|
||||
if self._thread:
|
||||
self._thread.join()
|
||||
|
||||
def _process_audio(self):
|
||||
"""Main audio processing loop."""
|
||||
_LOGGER.debug("Audio engine started.")
|
||||
|
||||
micro_features = MicroWakeWordFeatures()
|
||||
oww_features: Optional[OpenWakeWordFeatures] = None
|
||||
|
||||
wake_words: List[Union[MicroWakeWord, OpenWakeWord]] = []
|
||||
micro_inputs: List[np.ndarray] = []
|
||||
oww_inputs: List[np.ndarray] = []
|
||||
has_oww = False
|
||||
|
||||
# Track last activation time per wake word (by id or phrase)
|
||||
last_active_by_id: Dict[str, float] = {}
|
||||
|
||||
try:
|
||||
while True:
|
||||
# --- Handle Mute State ---
|
||||
if not self.state.mic_muted_event.is_set():
|
||||
_LOGGER.debug("Audio thread muted. Waiting...")
|
||||
self.state.mic_muted_event.wait()
|
||||
|
||||
if self.state.shutdown:
|
||||
return
|
||||
|
||||
# Unmuted: Clear buffers and prepare
|
||||
_LOGGER.debug("Audio thread unmuted. Flushing...")
|
||||
micro_features.reset()
|
||||
if oww_features is not None:
|
||||
oww_features.reset()
|
||||
|
||||
# Allow immediate activation (don't enforce refractory here)
|
||||
last_active_by_id.clear()
|
||||
|
||||
# Flush hardware buffer
|
||||
try:
|
||||
with self.mic.recorder(
|
||||
samplerate=16000,
|
||||
channels=1,
|
||||
blocksize=self.block_size,
|
||||
) as mic_in:
|
||||
mic_in.flush()
|
||||
except Exception as e:
|
||||
_LOGGER.warning("Error flushing mic: %s", e)
|
||||
|
||||
if self.state.shutdown:
|
||||
return
|
||||
|
||||
# --- Main Recording Loop ---
|
||||
_LOGGER.debug("Opening audio input device: %s", self.mic.name)
|
||||
with self.mic.recorder(
|
||||
samplerate=16000, channels=1, blocksize=self.block_size
|
||||
) as mic_in:
|
||||
while self.state.mic_muted_event.is_set():
|
||||
if self.state.shutdown:
|
||||
return
|
||||
|
||||
audio_chunk_array = mic_in.record(self.block_size).reshape(-1)
|
||||
audio_chunk = (
|
||||
(np.clip(audio_chunk_array, -1.0, 1.0) * 32767.0)
|
||||
.astype("<i2")
|
||||
.tobytes()
|
||||
)
|
||||
|
||||
if self.state.satellite is None:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
# CRITICAL FIX: Protect wake word reload with lock
|
||||
with self._wake_words_lock:
|
||||
# Update active wake words if changed
|
||||
if (not wake_words) or (self.state.wake_words_changed):
|
||||
self.state.wake_words_changed = False
|
||||
wake_words = [
|
||||
ww
|
||||
for ww in self.state.wake_words.values()
|
||||
if ww.id in self.state.active_wake_words
|
||||
]
|
||||
has_oww = any(
|
||||
isinstance(ww, OpenWakeWord) for ww in wake_words
|
||||
)
|
||||
|
||||
if has_oww and (oww_features is None):
|
||||
_LOGGER.debug(
|
||||
"Initializing OpenWakeWord features..."
|
||||
)
|
||||
oww_features = OpenWakeWordFeatures.from_builtin()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Rebuilt wake_words list: %s (has_oww=%s, active_ids=%s)",
|
||||
[
|
||||
getattr(ww, "wake_word", getattr(ww, "id", "?"))
|
||||
for ww in wake_words
|
||||
],
|
||||
has_oww,
|
||||
self.state.active_wake_words,
|
||||
)
|
||||
|
||||
try:
|
||||
# Feature extraction
|
||||
micro_inputs.clear()
|
||||
micro_inputs.extend(
|
||||
micro_features.process_streaming(audio_chunk)
|
||||
)
|
||||
|
||||
if has_oww and oww_features:
|
||||
oww_inputs.clear()
|
||||
oww_inputs.extend(
|
||||
oww_features.process_streaming(audio_chunk)
|
||||
)
|
||||
|
||||
# Wake word detection
|
||||
for wake_word in wake_words:
|
||||
activated = False
|
||||
if isinstance(wake_word, MicroWakeWord):
|
||||
if any(
|
||||
wake_word.process_streaming(mi)
|
||||
for mi in micro_inputs
|
||||
):
|
||||
activated = True
|
||||
elif isinstance(wake_word, OpenWakeWord):
|
||||
if any(
|
||||
p > 0.5
|
||||
for oi in oww_inputs
|
||||
for p in wake_word.process_streaming(oi)
|
||||
):
|
||||
activated = True
|
||||
|
||||
if activated:
|
||||
wake_phrase = getattr(
|
||||
wake_word, "wake_word", "wake word"
|
||||
)
|
||||
wake_id_attr = getattr(wake_word, "id", None)
|
||||
# Use id if available, otherwise phrase string as key
|
||||
id_key = wake_id_attr or wake_phrase
|
||||
|
||||
_LOGGER.debug(
|
||||
"Wake word ACTIVATED: %s (id=%s)",
|
||||
wake_phrase,
|
||||
wake_id_attr,
|
||||
)
|
||||
|
||||
now = time.monotonic()
|
||||
last_active = last_active_by_id.get(id_key)
|
||||
|
||||
# If refractory_seconds <= 0, disable gating
|
||||
refractory = max(self.state.refractory_seconds, 0.0)
|
||||
|
||||
if (last_active is None) or (
|
||||
(now - last_active) > refractory
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Wake word ACCEPTED (phrase=%s); "
|
||||
"triggering wakeup (Δt=%.2f, refractory=%.2f)",
|
||||
wake_phrase,
|
||||
0.0
|
||||
if last_active is None
|
||||
else now - last_active,
|
||||
refractory,
|
||||
)
|
||||
last_active_by_id[id_key] = now
|
||||
self.state.loop.call_soon_threadsafe(
|
||||
self.state.satellite.wakeup, wake_word
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Wake word REJECTED by refractory period "
|
||||
"(phrase=%s, Δt=%.2f < %.2f)",
|
||||
wake_phrase,
|
||||
now - last_active,
|
||||
refractory,
|
||||
)
|
||||
|
||||
# Stop word detection
|
||||
stopped = False
|
||||
for micro_input in micro_inputs:
|
||||
if self.state.stop_word.process_streaming(micro_input):
|
||||
stopped = True
|
||||
|
||||
if stopped:
|
||||
_LOGGER.debug(
|
||||
"Stop wake word ACTIVATED (id=%s, active_wake_words=%s)",
|
||||
getattr(self.state.stop_word, "id", None),
|
||||
self.state.active_wake_words,
|
||||
)
|
||||
|
||||
if (
|
||||
stopped
|
||||
and self.state.stop_word.id
|
||||
in self.state.active_wake_words
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Stop wake word ACCEPTED; calling satellite.stop()"
|
||||
)
|
||||
self.state.loop.call_soon_threadsafe(
|
||||
self.state.satellite.stop
|
||||
)
|
||||
elif stopped:
|
||||
_LOGGER.debug(
|
||||
"Stop wake word TRIGGERED but IGNORED because "
|
||||
"id=%s not in active_wake_words",
|
||||
getattr(self.state.stop_word, "id", None),
|
||||
)
|
||||
|
||||
# Pass audio to satellite (for voice streaming)
|
||||
self.state.loop.call_soon_threadsafe(
|
||||
self.state.satellite.handle_audio, audio_chunk
|
||||
)
|
||||
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error handling audio")
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.critical("A soundcard error occurred: %s", e)
|
||||
self.state.loop.call_soon_threadsafe(self.state.loop.stop)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""System output volume helpers.
|
||||
|
||||
LVA historically only adjusted mpv's per-player volume. On PipeWire/PulseAudio
|
||||
setups where the default sink volume starts low (e.g. 40%), HA shows LVA at
|
||||
100% while the actual OS sink remains capped.
|
||||
|
||||
This module keeps the OS output volume aligned with LVA's persisted
|
||||
preferences.volume_level (0.0–1.0).
|
||||
|
||||
We try backends in this order:
|
||||
1) PipeWire: wpctl
|
||||
2) PulseAudio (incl. pipewire-pulse): pactl
|
||||
3) ALSA: amixer
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _clamp01(v: float) -> float:
|
||||
try:
|
||||
v = float(v)
|
||||
except Exception:
|
||||
return 1.0
|
||||
if math.isnan(v) or math.isinf(v):
|
||||
return 1.0
|
||||
return max(0.0, min(1.0, v))
|
||||
|
||||
|
||||
def _pactl_sink_from_output_device(output_device: Optional[str]) -> str:
|
||||
"""Best-effort mapping of mpv audio-device -> pactl sink name.
|
||||
|
||||
LVA commonly uses:
|
||||
- "pipewire/<pactl sink name>"
|
||||
- "pulse/<pactl sink name>"
|
||||
- "alsa_output...." (already a sink name)
|
||||
|
||||
Fallback is @DEFAULT_SINK@.
|
||||
"""
|
||||
if not output_device:
|
||||
return "@DEFAULT_SINK@"
|
||||
|
||||
dev = output_device.strip()
|
||||
|
||||
for prefix in ("pipewire/", "pulse/"):
|
||||
if dev.startswith(prefix) and len(dev) > len(prefix):
|
||||
return dev[len(prefix) :]
|
||||
|
||||
if dev.startswith("alsa_output."):
|
||||
return dev
|
||||
|
||||
# Some users pass the raw sink name without a prefix.
|
||||
if "alsa_output." in dev:
|
||||
return dev
|
||||
|
||||
return "@DEFAULT_SINK@"
|
||||
|
||||
|
||||
def _run_cmd(cmd: list[str], timeout_s: float = 2.0) -> tuple[bool, str]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
ok = proc.returncode == 0
|
||||
out = (proc.stdout or "").strip()
|
||||
return ok, out
|
||||
except FileNotFoundError:
|
||||
return False, f"command not found: {cmd[0]}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "timeout"
|
||||
except Exception as e:
|
||||
return False, repr(e)
|
||||
|
||||
|
||||
def set_output_volume(
|
||||
volume_0_1: float,
|
||||
output_device: Optional[str] = None,
|
||||
max_volume_percent: int = 100,
|
||||
logger: logging.Logger = _LOGGER,
|
||||
) -> bool:
|
||||
"""Set OS output volume to match LVA volume (0.0–1.0).
|
||||
|
||||
`max_volume_percent` lets you map LVA's 100% to something above 100% on the
|
||||
OS sink (e.g. 150 for 1.5x), which can be useful on devices that need
|
||||
boosted output.
|
||||
|
||||
Returns True if any backend succeeded.
|
||||
"""
|
||||
vol = _clamp01(volume_0_1)
|
||||
try:
|
||||
max_pct = int(max_volume_percent)
|
||||
except Exception:
|
||||
max_pct = 100
|
||||
# Avoid weird values; allow >100 for boosted sinks.
|
||||
max_pct = max(0, min(200, max_pct))
|
||||
sink_scalar = vol * (max_pct / 100.0)
|
||||
|
||||
# --- 1) PipeWire: wpctl -------------------------------------------------
|
||||
if shutil.which("wpctl"):
|
||||
# wpctl accepts @DEFAULT_AUDIO_SINK@ and a linear factor (e.g. 0.40)
|
||||
ok, out = _run_cmd(
|
||||
[
|
||||
"wpctl",
|
||||
"set-volume",
|
||||
"@DEFAULT_AUDIO_SINK@",
|
||||
f"{sink_scalar:.3f}",
|
||||
]
|
||||
)
|
||||
if ok:
|
||||
logger.debug(
|
||||
"Set PipeWire sink volume via wpctl: scalar=%.3f (vol=%.3f max_pct=%d)",
|
||||
sink_scalar,
|
||||
vol,
|
||||
max_pct,
|
||||
)
|
||||
return True
|
||||
logger.debug("wpctl set-volume failed: %s", out)
|
||||
|
||||
# --- 2) PulseAudio: pactl ----------------------------------------------
|
||||
if shutil.which("pactl"):
|
||||
sink = _pactl_sink_from_output_device(output_device)
|
||||
pct = int(round(sink_scalar * 100.0))
|
||||
ok, out = _run_cmd(["pactl", "set-sink-volume", sink, f"{pct}%"])
|
||||
if ok:
|
||||
logger.debug(
|
||||
"Set Pulse sink volume via pactl: sink=%s pct=%d (vol=%.3f max_pct=%d)",
|
||||
sink,
|
||||
pct,
|
||||
vol,
|
||||
max_pct,
|
||||
)
|
||||
return True
|
||||
logger.debug("pactl set-sink-volume failed: %s", out)
|
||||
|
||||
# --- 3) ALSA: amixer ----------------------------------------------------
|
||||
if shutil.which("amixer"):
|
||||
pct = int(round(sink_scalar * 100.0))
|
||||
# Common mixer controls across SBC images.
|
||||
for control in ("Master", "PCM", "Speaker", "Headphone"):
|
||||
ok, out = _run_cmd(["amixer", "-q", "sset", control, f"{pct}%"])
|
||||
if ok:
|
||||
logger.debug(
|
||||
"Set ALSA volume via amixer: control=%s pct=%d (vol=%.3f max_pct=%d)",
|
||||
control,
|
||||
pct,
|
||||
vol,
|
||||
max_pct,
|
||||
)
|
||||
return True
|
||||
logger.debug("amixer sset %s failed: %s", control, out)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_output_volume(
|
||||
volume: float,
|
||||
output_device: Optional[str] = None,
|
||||
max_volume_percent: int = 100,
|
||||
attempts: int = 10,
|
||||
delay_seconds: float = 0.5,
|
||||
logger: logging.Logger = _LOGGER,
|
||||
) -> bool:
|
||||
"""Retry output volume sync during startup.
|
||||
|
||||
Useful at boot when PipeWire/Pulse/ALSA might not be fully ready yet.
|
||||
"""
|
||||
for i in range(1, max(1, attempts) + 1):
|
||||
ok = await asyncio.to_thread(
|
||||
set_output_volume,
|
||||
volume,
|
||||
output_device,
|
||||
max_volume_percent,
|
||||
logger,
|
||||
)
|
||||
if ok:
|
||||
return True
|
||||
if i < attempts:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
return False
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Hardware button controller (e.g. ReSpeaker 2-Mic HAT momentary switch).
|
||||
|
||||
This controller:
|
||||
- Short press:
|
||||
* If audio is playing (TTS or music): stop playback (like Stop wake word).
|
||||
* Otherwise: start a new conversation (like a wake word).
|
||||
- Long press:
|
||||
* Toggle microphone mute (via the existing set_mic_mute event).
|
||||
|
||||
Implementation note:
|
||||
- Uses a simple polling loop with RPi.GPIO instead of kernel edge detection.
|
||||
- On non-Raspberry Pi hosts (or where RPi.GPIO is unusable), the controller
|
||||
cleanly disables itself and logs an info message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .event_bus import EventBus
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Try to import RPi.GPIO, but gracefully handle *any* failure.
|
||||
# On non-RPi hosts, RPi.GPIO may be installed but will raise RuntimeError
|
||||
# at import time ("This module can only be run on a Raspberry Pi!").
|
||||
try: # pragma: no cover - behavior depends on host platform
|
||||
import RPi.GPIO as GPIO # type: ignore[import]
|
||||
except Exception: # ImportError, RuntimeError, etc.
|
||||
GPIO = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonRuntimeConfig:
|
||||
"""Runtime-safe config wrapper for the hardware button."""
|
||||
enabled: bool
|
||||
pin: int
|
||||
long_press_seconds: float
|
||||
# CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz)
|
||||
# 80% reduction in CPU usage for button monitoring
|
||||
poll_interval_seconds: float = 0.05 # 20 Hz polling
|
||||
|
||||
|
||||
class ButtonController:
|
||||
"""
|
||||
Hardware button handler for the ReSpeaker 2-Mic HAT (or any GPIO button).
|
||||
|
||||
This class:
|
||||
- Uses a background polling thread to watch the GPIO level.
|
||||
- Interprets level transitions as press/release.
|
||||
- Schedules actions on the asyncio loop via loop.call_soon_threadsafe().
|
||||
- Uses the EventBus to toggle mic mute.
|
||||
|
||||
On non-RPi hosts (or when RPi.GPIO isn't usable), it is effectively a no-op.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop,
|
||||
event_bus: EventBus,
|
||||
state: ServerState,
|
||||
config,
|
||||
) -> None:
|
||||
"""
|
||||
:param loop: asyncio event loop (state.loop).
|
||||
:param event_bus: Global EventBus.
|
||||
:param state: Global ServerState.
|
||||
:param config: Config.button (ButtonConfig-like object).
|
||||
"""
|
||||
self.loop = loop
|
||||
self.event_bus = event_bus
|
||||
self.state = state
|
||||
|
||||
# CRITICAL FIX: Default poll_interval_seconds to 0.05 instead of 0.01
|
||||
default_poll_interval = 0.05
|
||||
|
||||
self._cfg = ButtonRuntimeConfig(
|
||||
enabled=getattr(config, "enabled", False),
|
||||
pin=getattr(config, "pin", 17),
|
||||
long_press_seconds=float(
|
||||
getattr(config, "long_press_seconds", 1.0)
|
||||
),
|
||||
poll_interval_seconds=float(
|
||||
getattr(config, "poll_interval_seconds", default_poll_interval)
|
||||
),
|
||||
)
|
||||
|
||||
self._thread: threading.Thread | None = None
|
||||
self._press_time: float | None = None
|
||||
self._last_level: int | None = None
|
||||
|
||||
# If disabled in config, do nothing.
|
||||
if not self._cfg.enabled:
|
||||
_LOGGER.info(
|
||||
"ButtonController disabled in config; not initializing GPIO"
|
||||
)
|
||||
return
|
||||
|
||||
# If RPi.GPIO is unavailable or not usable on this host, also do nothing.
|
||||
if GPIO is None:
|
||||
_LOGGER.info(
|
||||
"RPi.GPIO not available or not usable on this host; "
|
||||
"hardware button support disabled"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
# Assume button wired as active-low with internal pull-up (common for HATs)
|
||||
GPIO.setup(self._cfg.pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
|
||||
self._last_level = GPIO.input(self._cfg.pin)
|
||||
_LOGGER.info(
|
||||
"ButtonController initialized on GPIO pin %s "
|
||||
"(long_press_seconds=%.2f, poll_interval=%.3fs)",
|
||||
self._cfg.pin,
|
||||
self._cfg.long_press_seconds,
|
||||
self._cfg.poll_interval_seconds,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to configure GPIO pin %s for button; "
|
||||
"hardware button support disabled",
|
||||
self._cfg.pin,
|
||||
)
|
||||
return
|
||||
|
||||
# Start polling thread
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name="ButtonControllerThread",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Background loop to poll the GPIO level and detect presses."""
|
||||
_LOGGER.debug("ButtonController polling thread started")
|
||||
|
||||
while not getattr(self.state, "shutdown", False):
|
||||
try:
|
||||
level = GPIO.input(self._cfg.pin) # type: ignore[call-arg]
|
||||
except Exception:
|
||||
_LOGGER.exception("Error reading GPIO pin %s", self._cfg.pin)
|
||||
break
|
||||
|
||||
if self._last_level is None:
|
||||
self._last_level = level
|
||||
|
||||
# Active-low: 0 = pressed, 1 = released
|
||||
if level != self._last_level:
|
||||
if level == 0:
|
||||
self._on_pressed()
|
||||
else:
|
||||
self._on_released()
|
||||
self._last_level = level
|
||||
|
||||
time.sleep(self._cfg.poll_interval_seconds)
|
||||
|
||||
_LOGGER.debug("ButtonController polling thread exiting")
|
||||
# Do NOT call GPIO.cleanup() here; other components may use GPIO as well.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# "Interrupt-like" handlers called from polling thread
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _on_pressed(self) -> None:
|
||||
self._press_time = time.monotonic()
|
||||
_LOGGER.debug("Button pressed (GPIO %s low)", self._cfg.pin)
|
||||
|
||||
def _on_released(self) -> None:
|
||||
if self._press_time is None:
|
||||
return
|
||||
|
||||
duration = time.monotonic() - self._press_time
|
||||
self._press_time = None
|
||||
_LOGGER.debug("Button released after %.3f seconds", duration)
|
||||
|
||||
if duration >= self._cfg.long_press_seconds:
|
||||
self._handle_long_press()
|
||||
else:
|
||||
self._handle_short_press()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# High-level behaviors
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _handle_short_press(self) -> None:
|
||||
"""Short press: wake / stop behavior."""
|
||||
# Snapshot playing state quickly
|
||||
tts_playing = bool(getattr(self.state.tts_player, "is_playing", False))
|
||||
music_playing = bool(getattr(self.state.music_player, "is_playing", False))
|
||||
|
||||
if tts_playing or music_playing:
|
||||
_LOGGER.debug(
|
||||
"Button short press while audio playing "
|
||||
"(tts=%s, music=%s) -> stopping playback",
|
||||
tts_playing,
|
||||
music_playing,
|
||||
)
|
||||
|
||||
# Stop TTS/timer via the same logic as the Stop wake word.
|
||||
if self.state.satellite is not None:
|
||||
self.loop.call_soon_threadsafe(self.state.satellite.stop)
|
||||
|
||||
# Also stop any music playback controlled by the LVA.
|
||||
self.loop.call_soon_threadsafe(self.state.music_player.stop)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Button short press with no audio playing -> manual wakeup"
|
||||
)
|
||||
if self.state.satellite is not None:
|
||||
# Use a dedicated manual wakeup to avoid faking a wake-word object.
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.state.satellite.manual_wakeup, "button"
|
||||
)
|
||||
|
||||
def _handle_long_press(self) -> None:
|
||||
"""Long press: toggle mic mute."""
|
||||
new_state = not self.state.mic_muted
|
||||
_LOGGER.debug(
|
||||
"Button long press: toggling mic mute -> %s", new_state
|
||||
)
|
||||
|
||||
# Use the standard set_mic_mute event so MicMuteHandler can:
|
||||
# - update ServerState.mic_muted
|
||||
# - control the mic_muted_event
|
||||
# - publish MQTT mute state
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.event_bus.publish,
|
||||
"set_mic_mute",
|
||||
{"state": new_state},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Linux Voice Assistant"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// Required
|
||||
// ---------------------------------------------------------------------------
|
||||
"app": {
|
||||
// Required
|
||||
"name": "My Linux Voice Assistant",
|
||||
|
||||
// Optional
|
||||
"wakeup_sound": "sounds/wake_word_triggered.flac",
|
||||
"timer_finished_sound": "sounds/timer_finished.flac",
|
||||
"preferences_file": "preferences.json",
|
||||
"debug": false
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: Audio I/O + Output Volume Sync
|
||||
// ---------------------------------------------------------------------------
|
||||
"audio": {
|
||||
// Optional:
|
||||
// - null/omit => default microphone
|
||||
// - string name => exact device name (soundcard backend)
|
||||
// - string number => index into soundcard device list (e.g. "0", "1", ...)
|
||||
"input_device": null,
|
||||
|
||||
// Optional: audio capture block size
|
||||
"input_block_size": 1024,
|
||||
|
||||
// Optional:
|
||||
// - null/omit => mpv chooses default output
|
||||
// - string => mpv audio-device (e.g. "pulse/alsa_output....")
|
||||
"output_device": null,
|
||||
|
||||
// Optional (NEW): Sync the host OS output device volume at startup to match
|
||||
// the volume stored in preferences.json (LVA MediaPlayer volume).
|
||||
//
|
||||
// When enabled, LVA will attempt (in order): wpctl -> pactl -> amixer.
|
||||
// This helps when PipeWire/Pulse/ALSA boots with a low default volume.
|
||||
//
|
||||
// Default: false
|
||||
"volume_sync": false,
|
||||
|
||||
// Optional (NEW): Maximum output volume percent for the underlying sink.
|
||||
//
|
||||
// Many PipeWire/PulseAudio sinks support >100% (e.g. 150%) "amplified" volume.
|
||||
// This sets the ceiling used when mapping LVA volume (0.0–1.0) to the sink.
|
||||
//
|
||||
// Example:
|
||||
// - max_volume_percent: 150
|
||||
// - LVA volume 1.0 => sink set to 150%
|
||||
// - LVA volume 0.5 => sink set to 75%
|
||||
//
|
||||
// Default: 100
|
||||
"max_volume_percent": 100
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: Wake word config
|
||||
// ---------------------------------------------------------------------------
|
||||
"wake_word": {
|
||||
// Optional: if omitted/empty, code falls back to defaults:
|
||||
// ["wakewords", "wakewords/openWakeWord"]
|
||||
"directories": [],
|
||||
|
||||
// Optional: default wake word model id
|
||||
"model": "okay_nabu",
|
||||
|
||||
// Optional: stop model id (micro wake word)
|
||||
"stop_model": "stop",
|
||||
|
||||
// Optional: debounce/refractory window after wake word triggers
|
||||
"refractory_seconds": 2.0,
|
||||
|
||||
// Optional: directory for downloaded models (relative to repo)
|
||||
"download_dir": "local"
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: ESPHome protocol server settings (the LVA exposes an ESPHome API)
|
||||
// ---------------------------------------------------------------------------
|
||||
"esphome": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 6053
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: LEDs
|
||||
// ---------------------------------------------------------------------------
|
||||
"led": {
|
||||
// Optional: if false, LED controller runs in no-op mode
|
||||
"enabled": true,
|
||||
|
||||
// "dotstar" | "neopixel" | "xvf3800"
|
||||
"led_type": "dotstar",
|
||||
|
||||
// "spi" | "gpio" | "usb"
|
||||
// - dotstar: "spi" or "gpio"
|
||||
// - neopixel: "spi" or "gpio"
|
||||
// - xvf3800: "usb" (current intended support)
|
||||
"interface": "spi",
|
||||
|
||||
// GPIO pins (BCM-ish via adafruit "board.Dxx" mapping) — used when interface="gpio"
|
||||
// For DotStar GPIO: both clock_pin + data_pin are used
|
||||
// For NeoPixel GPIO: only data_pin is used (clock_pin ignored)
|
||||
// For xvf3800: unused and ignored
|
||||
"clock_pin": 13,
|
||||
"data_pin": 12,
|
||||
|
||||
// Default number of LEDs (may be overridden by preferences.json after first run)
|
||||
"num_leds": 3
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: MQTT (enabled automatically when host is set)
|
||||
// ---------------------------------------------------------------------------
|
||||
"mqtt": {
|
||||
// If set, mqtt.enabled becomes true automatically
|
||||
"host": null,
|
||||
|
||||
"port": 1883,
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional: Button controller
|
||||
// ---------------------------------------------------------------------------
|
||||
"button": {
|
||||
// Master enable
|
||||
"enabled": false,
|
||||
|
||||
// "gpio" | "xvf3800"
|
||||
// - gpio: short/long press logic (pin + long_press_seconds)
|
||||
// - xvf3800: uses the XVF3800 built-in mute button as a pure mute toggle
|
||||
"mode": "gpio",
|
||||
|
||||
// gpio mode only
|
||||
"pin": 17,
|
||||
"long_press_seconds": 1.0,
|
||||
|
||||
// Polling interval (used by both gpio + xvf3800 button controllers)
|
||||
"poll_interval_seconds": 0.01
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Configuration models for the application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Configuration Dataclasses
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""General application settings."""
|
||||
name: str
|
||||
wakeup_sound: str = "sounds/wake_word_triggered.flac"
|
||||
timer_finished_sound: str = "sounds/timer_finished.flac"
|
||||
preferences_file: str = "preferences.json"
|
||||
debug: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
"""Settings for audio input and output."""
|
||||
input_device: Optional[str] = None
|
||||
input_block_size: int = 1024
|
||||
output_device: Optional[str] = None
|
||||
|
||||
# If True, LVA will attempt to set the OS sink volume to match the persisted
|
||||
# volume in preferences.json on startup (PipeWire/PulseAudio/ALSA best-effort).
|
||||
# Default is False to avoid surprising changes on systems where users manage
|
||||
# volume externally.
|
||||
volume_sync: bool = False
|
||||
|
||||
# Maximum sink volume to map to LVA's 100% volume.
|
||||
#
|
||||
# Example: if max_volume_percent=150 then when LVA's media player reports
|
||||
# 100% (preferences.volume_level == 1.0), the underlying sink will be set
|
||||
# to 150% (or 1.5 for backends that use scalar volume).
|
||||
#
|
||||
# This is useful for devices that need >100% gain on PipeWire/Pulse/ALSA.
|
||||
max_volume_percent: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class WakeWordConfig:
|
||||
"""Settings for wake word detection."""
|
||||
directories: List[str] = field(default_factory=list)
|
||||
model: str = "okay_nabu"
|
||||
stop_model: str = "stop"
|
||||
refractory_seconds: float = 2.0
|
||||
download_dir: str = "local"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ESPHomeConfig:
|
||||
"""Settings for the ESPHome API server."""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 6053
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedConfig:
|
||||
"""Settings for LEDs."""
|
||||
enabled: bool = True
|
||||
|
||||
# Supported values include:
|
||||
# - "dotstar" / "neopixel" for Pi-attached LED strips
|
||||
# - "xvf3800" for the ReSpeaker XVF3800 USB LED ring backend
|
||||
led_type: str = "dotstar"
|
||||
|
||||
# For dotstar/neopixel: "spi" or "gpio"
|
||||
# For xvf3800: "usb"
|
||||
interface: str = "spi"
|
||||
|
||||
# GPIO pin numbers used when interface="gpio"
|
||||
clock_pin: int = 13
|
||||
data_pin: int = 12
|
||||
|
||||
# Note: overridden by 'preferences.json' if it exists
|
||||
num_leds: int = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class MqttConfig:
|
||||
"""Settings for the MQTT client."""
|
||||
enabled: bool = False
|
||||
host: Optional[str] = None
|
||||
port: int = 1883
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonConfig:
|
||||
"""Settings for a hardware momentary button."""
|
||||
|
||||
# Overall enable/disable (default: off)
|
||||
enabled: bool = False
|
||||
|
||||
# mode:
|
||||
# - "gpio" -> legacy GPIO button (e.g. ReSpeaker 2-Mic HAT)
|
||||
# - "xvf3800"-> USB-based mute integration for the ReSpeaker XVF3800
|
||||
mode: str = "gpio"
|
||||
|
||||
# BCM GPIO pin number for the button input (gpio mode only).
|
||||
pin: int = 17
|
||||
|
||||
# Press duration (in seconds) to be considered a "long press" (gpio mode).
|
||||
long_press_seconds: float = 1.0
|
||||
|
||||
# Poll interval used by both GPIO and XVF3800 controllers.
|
||||
poll_interval_seconds: float = 0.01
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Main configuration object."""
|
||||
app: AppConfig
|
||||
audio: AudioConfig = field(default_factory=AudioConfig)
|
||||
wake_word: WakeWordConfig = field(default_factory=WakeWordConfig)
|
||||
esphome: ESPHomeConfig = field(default_factory=ESPHomeConfig)
|
||||
led: LedConfig = field(default_factory=LedConfig)
|
||||
mqtt: MqttConfig = field(default_factory=MqttConfig)
|
||||
button: ButtonConfig = field(default_factory=ButtonConfig)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper Function
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def load_config_from_json(config_path: Path) -> Config:
|
||||
"""Loads configuration from a JSON file and populates dataclasses."""
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
_LOGGER.critical("Configuration file not found at: %s", config_path)
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
_LOGGER.critical("Error parsing configuration file: %s", e)
|
||||
raise
|
||||
|
||||
if "app" not in raw_data:
|
||||
raise ValueError("Configuration file must contain an 'app' section with a 'name'.")
|
||||
|
||||
app_config = AppConfig(**raw_data.get("app", {}))
|
||||
audio_config = AudioConfig(**raw_data.get("audio", {}))
|
||||
wake_word_config = WakeWordConfig(**raw_data.get("wake_word", {}))
|
||||
esphome_config = ESPHomeConfig(**raw_data.get("esphome", {}))
|
||||
led_config = LedConfig(**raw_data.get("led", {}))
|
||||
mqtt_config = MqttConfig(**raw_data.get("mqtt", {}))
|
||||
button_config = ButtonConfig(**raw_data.get("button", {}))
|
||||
|
||||
# Back-compat: allow top-level "volume_sync" (preferred location is audio.volume_sync)
|
||||
if "volume_sync" in raw_data and "volume_sync" not in raw_data.get("audio", {}):
|
||||
try:
|
||||
audio_config.volume_sync = bool(raw_data.get("volume_sync"))
|
||||
except Exception:
|
||||
# If it's something strange, just leave default.
|
||||
pass
|
||||
|
||||
# Set MQTT 'enabled' flag
|
||||
if mqtt_config.host:
|
||||
mqtt_config.enabled = True
|
||||
|
||||
return Config(
|
||||
app=app_config,
|
||||
audio=audio_config,
|
||||
wake_word=wake_word_config,
|
||||
esphome=esphome_config,
|
||||
led=led_config,
|
||||
mqtt=mqtt_config,
|
||||
button=button_config,
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import Callable, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
|
||||
@@ -17,36 +17,38 @@ from .api_server import APIServer
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .util import call_all
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import ServerState
|
||||
|
||||
|
||||
class ESPHomeEntity:
|
||||
def __init__(self, server: APIServer) -> None:
|
||||
def __init__(self, server: APIServer, state: "ServerState") -> None:
|
||||
self.server = server
|
||||
self.state = state
|
||||
|
||||
@abstractmethod
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MediaPlayerEntity(ESPHomeEntity):
|
||||
def __init__(
|
||||
self,
|
||||
server: APIServer,
|
||||
state: "ServerState",
|
||||
key: int,
|
||||
name: str,
|
||||
object_id: str,
|
||||
music_player: MpvMediaPlayer,
|
||||
announce_player: MpvMediaPlayer,
|
||||
) -> None:
|
||||
ESPHomeEntity.__init__(self, server)
|
||||
super().__init__(server, state)
|
||||
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.object_id = object_id
|
||||
self.state = MediaPlayerState.IDLE
|
||||
self.volume = 1.0
|
||||
self.state_enum = MediaPlayerState.IDLE
|
||||
self.volume = state.preferences.volume_level # Initialize with saved volume
|
||||
self.muted = False
|
||||
self.music_player = music_player
|
||||
self.announce_player = announce_player
|
||||
@@ -97,19 +99,33 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
if msg.has_media_url:
|
||||
announcement = msg.has_announcement and msg.announcement
|
||||
yield from self.play(msg.media_url, announcement=announcement)
|
||||
|
||||
elif msg.has_command:
|
||||
if msg.command == MediaPlayerCommand.PAUSE:
|
||||
self.music_player.pause()
|
||||
yield self._update_state(MediaPlayerState.PAUSED)
|
||||
|
||||
elif msg.command == MediaPlayerCommand.PLAY:
|
||||
self.music_player.resume()
|
||||
yield self._update_state(MediaPlayerState.PLAYING)
|
||||
elif msg.has_volume:
|
||||
volume = int(msg.volume * 100)
|
||||
self.music_player.set_volume(volume)
|
||||
self.announce_player.set_volume(volume)
|
||||
self.volume = msg.volume
|
||||
yield self._update_state(self.state)
|
||||
|
||||
elif msg.command == MediaPlayerCommand.STOP:
|
||||
self.music_player.stop()
|
||||
yield self._update_state(MediaPlayerState.IDLE)
|
||||
|
||||
if msg.has_volume:
|
||||
# This block is called when the volume slider changes in HA
|
||||
self.volume = msg.volume # HA sends volume as 0.0-1.0
|
||||
volume_int = int(self.volume * 100)
|
||||
self.music_player.set_volume(volume_int)
|
||||
self.announce_player.set_volume(volume_int)
|
||||
|
||||
# Save the new volume level to preferences
|
||||
self.state.preferences.volume_level = self.volume
|
||||
self.state.save_preferences()
|
||||
|
||||
yield self._update_state(self.state_enum)
|
||||
|
||||
elif isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesMediaPlayerResponse(
|
||||
object_id=self.object_id,
|
||||
@@ -117,17 +133,18 @@ class MediaPlayerEntity(ESPHomeEntity):
|
||||
name=self.name,
|
||||
supports_pause=True,
|
||||
)
|
||||
|
||||
elif isinstance(msg, SubscribeHomeAssistantStatesRequest):
|
||||
yield self._get_state_message()
|
||||
|
||||
def _update_state(self, new_state: MediaPlayerState) -> MediaPlayerStateResponse:
|
||||
self.state = new_state
|
||||
self.state_enum = new_state
|
||||
return self._get_state_message()
|
||||
|
||||
def _get_state_message(self) -> MediaPlayerStateResponse:
|
||||
return MediaPlayerStateResponse(
|
||||
key=self.key,
|
||||
state=self.state,
|
||||
state=self.state_enum,
|
||||
volume=self.volume,
|
||||
muted=self.muted,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""A simple synchronous publish/subscribe event bus."""
|
||||
|
||||
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):
|
||||
self.topics: Dict[str, List[Callable[[Any], None]]] = {}
|
||||
|
||||
def subscribe(self, topic: str, listener: Callable[[Any], None]) -> None:
|
||||
"""Subscribes a listener to a topic."""
|
||||
if topic not in self.topics:
|
||||
self.topics[topic] = []
|
||||
self.topics[topic].append(listener)
|
||||
_LOGGER.debug(f"Subscribed listener to topic '{topic}'")
|
||||
|
||||
def publish(self, topic: str, data: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Publishes an event to all subscribed listeners."""
|
||||
if data is None:
|
||||
data = {}
|
||||
|
||||
data['__topic'] = topic
|
||||
|
||||
listeners = self.topics.get(topic, [])
|
||||
_LOGGER.debug(f"Publishing event to {len(listeners)} listeners on topic '{topic}'")
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(data)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in event listener for topic %s", topic)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client helpers for subscriptions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
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 must call `self._subscribe_all_methods()` in their __init__.
|
||||
"""
|
||||
def __init__(self, event_bus: EventBus):
|
||||
self.event_bus = event_bus
|
||||
# Note: Subclasses must call self._subscribe_all_methods()
|
||||
# after their own __init__ is complete.
|
||||
|
||||
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.event_bus.subscribe(method_name, method)
|
||||
@@ -0,0 +1,688 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional hardware imports (board-safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
import board # type: ignore[import]
|
||||
except Exception:
|
||||
board = None # type: ignore[assignment]
|
||||
_LOGGER.warning(
|
||||
"Adafruit 'board' module not available or unsupported on this platform; "
|
||||
"DotStar/NeoPixel GPIO/SPI LED backends will be disabled. "
|
||||
"XVF3800 USB LED backend is unaffected."
|
||||
)
|
||||
|
||||
from .config import LedConfig
|
||||
from .event_bus import EventBus, EventHandler, subscribe
|
||||
from .models import Preferences
|
||||
|
||||
# Default Colors
|
||||
_OFF = (0, 0, 0)
|
||||
_BLUE = (0, 0, 255)
|
||||
_YELLOW = (255, 255, 0)
|
||||
_GREEN = (0, 255, 0)
|
||||
_DIM_RED = (50, 0, 0)
|
||||
_ORANGE = (255, 165, 0)
|
||||
_PURPLE = (128, 0, 255)
|
||||
|
||||
|
||||
class LedController(EventHandler):
|
||||
def __init__(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
event_bus: EventBus,
|
||||
config: LedConfig,
|
||||
preferences: Preferences,
|
||||
):
|
||||
super().__init__(event_bus)
|
||||
self.loop = loop
|
||||
self.num_leds = preferences.num_leds # Get num_leds from preferences
|
||||
self.current_task: Optional[asyncio.Future] = None
|
||||
self._is_ready = False
|
||||
self.leds = None
|
||||
|
||||
# Backend mode:
|
||||
# "pixels" -> DotStar / NeoPixel via Adafruit drivers
|
||||
# "xvf3800" -> XVF3800 USB LED ring backend
|
||||
self._backend_mode: str = "pixels"
|
||||
self._xvf3800_backend = None
|
||||
|
||||
# Configured LED behavior
|
||||
self.configs = {
|
||||
"idle": {"effect": "off", "color": _PURPLE, "brightness": 0.5},
|
||||
"listening": {"effect": "medium_pulse", "color": _BLUE, "brightness": 0.5},
|
||||
"thinking": {"effect": "spin", "color": _YELLOW, "brightness": 0.8},
|
||||
"responding": {"effect": "medium_pulse", "color": _GREEN, "brightness": 0.5},
|
||||
"error": {"effect": "fast_blink", "color": _ORANGE, "brightness": 1.0},
|
||||
}
|
||||
|
||||
# Determine whether hardware LEDs should be used at all
|
||||
config_enabled = getattr(config, "enabled", True)
|
||||
|
||||
# For XVF3800 we do NOT require the Adafruit 'board' module.
|
||||
if config.led_type == "xvf3800" and config.interface == "usb":
|
||||
self._enabled = bool(config_enabled)
|
||||
else:
|
||||
self._enabled = bool(config_enabled) and (board is not None)
|
||||
|
||||
if not config_enabled:
|
||||
_LOGGER.info(
|
||||
"LEDs disabled in config (led.enabled = false); "
|
||||
"LedController will run in no-op mode."
|
||||
)
|
||||
elif not self._enabled:
|
||||
_LOGGER.warning(
|
||||
"LED hardware libraries not available on this platform for led_type=%s; "
|
||||
"LedController will run in no-op mode.",
|
||||
config.led_type,
|
||||
)
|
||||
|
||||
# Always subscribe to events so MQTT + state logic remains intact,
|
||||
# even when LEDs are effectively disabled.
|
||||
self._subscribe_all_methods()
|
||||
|
||||
# If LEDs are not enabled at all, skip hardware init
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# XVF3800 USB LED backend
|
||||
# -------------------------------------------------------------------
|
||||
if config.led_type == "xvf3800" and config.interface == "usb":
|
||||
try:
|
||||
from .xvf3800_led_backend import XVF3800LedBackend # type: ignore[import]
|
||||
|
||||
self._xvf3800_backend = XVF3800LedBackend()
|
||||
self._backend_mode = "xvf3800"
|
||||
self._is_ready = True
|
||||
# XVF3800 ring has a fixed LED count (typically 12)
|
||||
self.num_leds = int(getattr(self._xvf3800_backend, "ring_led_count", 12))
|
||||
_LOGGER.info(
|
||||
"LED Controller initialized for XVF3800 USB LED ring (num_leds=%d).",
|
||||
self.num_leds,
|
||||
)
|
||||
self.run_action("startup_sequence")
|
||||
except Exception:
|
||||
# Any hardware-related failure leaves us in no-op mode
|
||||
self._is_ready = False
|
||||
self._enabled = False
|
||||
self._xvf3800_backend = None
|
||||
_LOGGER.exception(
|
||||
"Failed to initialize XVF3800 LED backend. LEDs will be disabled."
|
||||
)
|
||||
return
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Hardware initialization (DotStar / NeoPixel via Adafruit drivers)
|
||||
# -------------------------------------------------------------------
|
||||
try:
|
||||
if config.led_type == "neopixel":
|
||||
if config.interface == "spi":
|
||||
import busio
|
||||
import neopixel_spi
|
||||
|
||||
_LOGGER.debug(
|
||||
"Initializing %d NeoPixel LEDs on hardware SPI", self.num_leds
|
||||
)
|
||||
spi = busio.SPI(board.SCLK, MOSI=board.MOSI)
|
||||
self.leds = neopixel_spi.NeoPixel_SPI(
|
||||
spi, self.num_leds, auto_write=False
|
||||
)
|
||||
else: # GPIO
|
||||
import neopixel
|
||||
|
||||
_LOGGER.debug(
|
||||
"Initializing %d NeoPixel LEDs on GPIO data=%s",
|
||||
self.num_leds,
|
||||
config.data_pin,
|
||||
)
|
||||
pin_object = getattr(board, f"D{config.data_pin}")
|
||||
self.leds = neopixel.NeoPixel(
|
||||
pin_object, self.num_leds, auto_write=False
|
||||
)
|
||||
else: # dotstar
|
||||
import adafruit_dotstar
|
||||
|
||||
if config.interface == "gpio":
|
||||
_LOGGER.debug(
|
||||
"Initializing %d DotStar LEDs on GPIO data=%s, clock=%s",
|
||||
self.num_leds,
|
||||
config.data_pin,
|
||||
config.clock_pin,
|
||||
)
|
||||
data_pin_obj = getattr(board, f"D{config.data_pin}")
|
||||
clock_pin_obj = getattr(board, f"D{config.clock_pin}")
|
||||
self.leds = adafruit_dotstar.DotStar(
|
||||
clock_pin_obj, data_pin_obj, self.num_leds, auto_write=False
|
||||
)
|
||||
else: # SPI
|
||||
_LOGGER.debug(
|
||||
"Initializing %d DotStar LEDs on hardware SPI", self.num_leds
|
||||
)
|
||||
self.leds = adafruit_dotstar.DotStar(
|
||||
board.SCLK, board.MOSI, self.num_leds, auto_write=False
|
||||
)
|
||||
|
||||
self._backend_mode = "pixels"
|
||||
self._is_ready = True
|
||||
_LOGGER.info(
|
||||
"LED Controller initialized for %d %s LEDs.",
|
||||
self.num_leds,
|
||||
config.led_type,
|
||||
)
|
||||
self.run_action("startup_sequence")
|
||||
|
||||
except Exception:
|
||||
# Any hardware-related failure leaves us in no-op mode
|
||||
self._is_ready = False
|
||||
self.leds = None
|
||||
_LOGGER.exception(
|
||||
"Failed to initialize LED controller. LEDs will be disabled."
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def run_action(self, action_method_name: str, *args: Any) -> None:
|
||||
"""Schedule an LED coroutine on the event loop."""
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self.current_task and not self.current_task.done():
|
||||
self.current_task.cancel()
|
||||
|
||||
coro = getattr(self, action_method_name)(*args)
|
||||
self.current_task = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
||||
|
||||
def _apply_state_effect(self, state_name: str, publish_state: bool = True):
|
||||
config = self.configs.get(state_name, self.configs["idle"])
|
||||
_LOGGER.debug(
|
||||
"Applying effect for state '%s': %s", state_name, config["effect"]
|
||||
)
|
||||
self.run_action(config["effect"], config["color"], config["brightness"])
|
||||
|
||||
if publish_state:
|
||||
config["state_name"] = state_name
|
||||
self.event_bus.publish("publish_state_to_mqtt", config)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# XVF3800-specific helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
async def _xvf3800_apply_effect(
|
||||
self,
|
||||
effect_name: str,
|
||||
color: Tuple[int, int, int],
|
||||
brightness: float,
|
||||
) -> None:
|
||||
"""Map generic effect/color/brightness to XVF3800 legacy LED controls."""
|
||||
if not (self._enabled and self._is_ready and self._xvf3800_backend):
|
||||
return
|
||||
|
||||
# Clamp brightness 0..1 and map to 0..255
|
||||
brightness = max(0.0, min(1.0, float(brightness)))
|
||||
brightness_255 = int(brightness * 255)
|
||||
|
||||
# Effect mapping to XVF3800 legacy modes
|
||||
effect_map = {
|
||||
"off": 0, # LED_EFFECT = off
|
||||
"solid": 3, # single color
|
||||
"slow_pulse": 1, # breath
|
||||
"medium_pulse": 1, # breath
|
||||
"fast_pulse": 1, # breath
|
||||
"slow_blink": 1, # approximate with breath
|
||||
"medium_blink": 1, # approximate with breath
|
||||
"fast_blink": 1, # approximate with breath
|
||||
"spin": 2, # rainbow as a stand-in for "spin"
|
||||
}
|
||||
|
||||
speed_map = {
|
||||
"off": 0,
|
||||
"solid": 0,
|
||||
"slow_pulse": 0,
|
||||
"medium_pulse": 1,
|
||||
"fast_pulse": 2,
|
||||
"slow_blink": 0,
|
||||
"medium_blink": 1,
|
||||
"fast_blink": 2,
|
||||
"spin": 1,
|
||||
}
|
||||
|
||||
effect_id = effect_map.get(effect_name, 3)
|
||||
speed_id = speed_map.get(effect_name, 1)
|
||||
|
||||
r, g, b = color
|
||||
try:
|
||||
# Apply brightness, color, speed, and effect via USB control transfers.
|
||||
self._xvf3800_backend.set_brightness(brightness_255)
|
||||
if effect_name != "off":
|
||||
self._xvf3800_backend.set_color(r, g, b)
|
||||
self._xvf3800_backend.set_speed(speed_id)
|
||||
self._xvf3800_backend.set_effect(effect_id)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Error sending LED effect '%s' to XVF3800 backend", effect_name
|
||||
)
|
||||
|
||||
# Keep coroutine alive until cancelled so that a new effect
|
||||
# can cancel the previous one consistently.
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# LED effect coroutines
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# XVF3800 per-LED helpers (newer firmware)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _xvf3800_has_per_led(self) -> bool:
|
||||
return bool(getattr(self._xvf3800_backend, "supports_per_led", False))
|
||||
|
||||
def _xvf3800_ring_count(self) -> int:
|
||||
return int(getattr(self._xvf3800_backend, "ring_led_count", 12))
|
||||
|
||||
def _xvf3800_rgb_clamp(self, color):
|
||||
r, g, b = color
|
||||
return (
|
||||
max(0, min(255, int(r))),
|
||||
max(0, min(255, int(g))),
|
||||
max(0, min(255, int(b))),
|
||||
)
|
||||
|
||||
def _xvf3800_brightness_255(self, brightness: float) -> int:
|
||||
b = max(0.0, min(1.0, float(brightness)))
|
||||
return int(b * 255)
|
||||
|
||||
def _xvf3800_apply_ring_solid(self, color, brightness: float) -> None:
|
||||
"""Per-LED solid ring.
|
||||
|
||||
NOTE: Some XVF3800 firmwares do not apply LED_BRIGHTNESS to LED_RING_COLOR output.
|
||||
To keep behavior consistent, we scale the RGB values by brightness and write the ring
|
||||
colors directly.
|
||||
"""
|
||||
if not self._xvf3800_backend:
|
||||
return
|
||||
r, g, b = self._xvf3800_rgb_clamp(color)
|
||||
brightness = max(0.0, min(1.0, float(brightness)))
|
||||
|
||||
# Ensure firmware effects don't override per-LED output.
|
||||
try:
|
||||
self._xvf3800_backend.set_effect(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rs = int(r * brightness)
|
||||
gs = int(g * brightness)
|
||||
bs = int(b * brightness)
|
||||
self._xvf3800_backend.set_ring_solid(rs, gs, bs)
|
||||
|
||||
def _xvf3800_apply_ring_clear(self) -> None:
|
||||
if not self._xvf3800_backend:
|
||||
return
|
||||
try:
|
||||
self._xvf3800_backend.set_effect(0)
|
||||
except Exception:
|
||||
pass
|
||||
self._xvf3800_backend.clear_ring()
|
||||
|
||||
async def startup_sequence(self, color=None, brightness=None):
|
||||
# Use a simple green blink on startup for all backends.
|
||||
await self.blink(_GREEN, 1.0)
|
||||
|
||||
|
||||
async def off(self, color, brightness):
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self._backend_mode == "xvf3800":
|
||||
# Prefer per-LED ring control when supported by firmware.
|
||||
if self._xvf3800_has_per_led():
|
||||
try:
|
||||
self._xvf3800_apply_ring_clear()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error sending per-LED OFF to XVF3800")
|
||||
return
|
||||
|
||||
await self._xvf3800_apply_effect("off", _OFF, 0.0)
|
||||
return
|
||||
|
||||
self.leds.fill(_OFF)
|
||||
self.leds.show()
|
||||
|
||||
|
||||
async def solid(self, color: Tuple[int, int, int], brightness: float):
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self._backend_mode == "xvf3800":
|
||||
if self._xvf3800_has_per_led():
|
||||
try:
|
||||
self._xvf3800_apply_ring_solid(color, brightness)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error sending per-LED SOLID to XVF3800")
|
||||
return
|
||||
|
||||
await self._xvf3800_apply_effect("solid", color, brightness)
|
||||
return
|
||||
|
||||
r, g, b = color
|
||||
self.leds.fill((int(r * brightness), int(g * brightness), int(b * brightness)))
|
||||
self.leds.show()
|
||||
|
||||
async def blink(self, color, brightness=1.0):
|
||||
await self.medium_blink(color, brightness)
|
||||
|
||||
|
||||
async def _base_pulse(
|
||||
self,
|
||||
effect_name: str,
|
||||
color: Tuple[int, int, int],
|
||||
brightness: float,
|
||||
speed: float,
|
||||
):
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self._backend_mode == "xvf3800":
|
||||
if self._xvf3800_has_per_led():
|
||||
try:
|
||||
r, g, b = self._xvf3800_rgb_clamp(color)
|
||||
# Pulse by scaling the per-LED RGB values (some firmwares don't apply LED_BRIGHTNESS to LED_RING_COLOR).
|
||||
self._xvf3800_backend.set_effect(0)
|
||||
brightness = max(0.0, min(1.0, float(brightness)))
|
||||
while True:
|
||||
for i in range(0, 101, 10):
|
||||
s = (i / 100.0) * brightness
|
||||
self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s))
|
||||
await asyncio.sleep(speed)
|
||||
for i in range(100, -1, -10):
|
||||
s = (i / 100.0) * brightness
|
||||
self._xvf3800_backend.set_ring_solid(int(r * s), int(g * s), int(b * s))
|
||||
await asyncio.sleep(speed)
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Error sending per-LED PULSE to XVF3800")
|
||||
return
|
||||
|
||||
await self._xvf3800_apply_effect(effect_name, color, brightness)
|
||||
return
|
||||
|
||||
try:
|
||||
r, g, b = color
|
||||
while True:
|
||||
for i in range(0, 101, 10):
|
||||
mod = i / 100.0 * brightness
|
||||
self.leds.fill((int(r * mod), int(g * mod), int(b * mod)))
|
||||
self.leds.show()
|
||||
await asyncio.sleep(speed)
|
||||
for i in range(100, -1, -10):
|
||||
mod = i / 100.0 * brightness
|
||||
self.leds.fill((int(r * mod), int(g * mod), int(b * mod)))
|
||||
self.leds.show()
|
||||
await asyncio.sleep(speed)
|
||||
except asyncio.CancelledError:
|
||||
if self._enabled and self._is_ready and self.leds is not None:
|
||||
self.leds.fill(_OFF)
|
||||
self.leds.show()
|
||||
|
||||
async def slow_pulse(self, color, brightness):
|
||||
await self._base_pulse("slow_pulse", color, brightness, 0.05)
|
||||
|
||||
async def medium_pulse(self, color, brightness):
|
||||
await self._base_pulse("medium_pulse", color, brightness, 0.02)
|
||||
|
||||
async def fast_pulse(self, color, brightness):
|
||||
await self._base_pulse("fast_pulse", color, brightness, 0.008)
|
||||
|
||||
|
||||
async def _base_blink(
|
||||
self,
|
||||
effect_name: str,
|
||||
color: Tuple[int, int, int],
|
||||
brightness: float,
|
||||
speed: float,
|
||||
):
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self._backend_mode == "xvf3800":
|
||||
if self._xvf3800_has_per_led():
|
||||
try:
|
||||
r, g, b = self._xvf3800_rgb_clamp(color)
|
||||
self._xvf3800_backend.set_effect(0)
|
||||
brightness = max(0.0, min(1.0, float(brightness)))
|
||||
while True:
|
||||
# On
|
||||
self._xvf3800_backend.set_ring_solid(int(r * brightness), int(g * brightness), int(b * brightness))
|
||||
await asyncio.sleep(speed)
|
||||
# Off
|
||||
self._xvf3800_backend.clear_ring()
|
||||
await asyncio.sleep(speed)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
self._xvf3800_apply_ring_clear()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Error sending per-LED BLINK to XVF3800")
|
||||
return
|
||||
|
||||
await self._xvf3800_apply_effect(effect_name, color, brightness)
|
||||
return
|
||||
|
||||
try:
|
||||
r, g, b = color
|
||||
bright_color = (int(r * brightness), int(g * brightness), int(b * brightness))
|
||||
while True:
|
||||
self.leds.fill(bright_color)
|
||||
self.leds.show()
|
||||
await asyncio.sleep(speed)
|
||||
self.leds.fill(_OFF)
|
||||
self.leds.show()
|
||||
await asyncio.sleep(speed)
|
||||
except asyncio.CancelledError:
|
||||
if self._enabled and self._is_ready and self.leds is not None:
|
||||
self.leds.fill(_OFF)
|
||||
self.leds.show()
|
||||
|
||||
async def slow_blink(self, color, brightness):
|
||||
await self._base_blink("slow_blink", color, brightness, 1.0)
|
||||
|
||||
async def medium_blink(self, color, brightness):
|
||||
await self._base_blink("medium_blink", color, brightness, 0.5)
|
||||
|
||||
async def fast_blink(self, color, brightness):
|
||||
await self._base_blink("fast_blink", color, brightness, 0.1)
|
||||
|
||||
|
||||
async def spin(
|
||||
self, color: Tuple[int, int, int], brightness: float, speed: float = 0.1
|
||||
):
|
||||
if not (self._enabled and self._is_ready):
|
||||
return
|
||||
|
||||
if self._backend_mode == "xvf3800":
|
||||
if self._xvf3800_has_per_led():
|
||||
try:
|
||||
r, g, b = self._xvf3800_rgb_clamp(color)
|
||||
ring_n = self._xvf3800_ring_count()
|
||||
self._xvf3800_backend.set_effect(0)
|
||||
self._xvf3800_backend.set_brightness(self._xvf3800_brightness_255(brightness))
|
||||
|
||||
i = 0
|
||||
while True:
|
||||
colors = [(0, 0, 0)] * ring_n
|
||||
colors[i % ring_n] = (r, g, b)
|
||||
self._xvf3800_backend.set_ring_rgb(colors)
|
||||
i += 1
|
||||
await asyncio.sleep(speed)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
self._xvf3800_apply_ring_clear()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Error sending per-LED SPIN to XVF3800")
|
||||
return
|
||||
|
||||
await self._xvf3800_apply_effect("spin", color, brightness)
|
||||
return
|
||||
|
||||
try:
|
||||
i = 0
|
||||
bright_color = (
|
||||
int(color[0] * brightness),
|
||||
int(color[1] * brightness),
|
||||
int(color[2] * brightness),
|
||||
)
|
||||
while True:
|
||||
self.leds.fill(_OFF)
|
||||
self.leds[i % self.num_leds] = bright_color
|
||||
self.leds.show()
|
||||
i += 1
|
||||
await asyncio.sleep(speed)
|
||||
except asyncio.CancelledError:
|
||||
if self._enabled and self._is_ready and self.leds is not None:
|
||||
self.leds.fill(_OFF)
|
||||
self.leds.show()
|
||||
|
||||
@subscribe
|
||||
def voice_idle(self, data: dict):
|
||||
self._apply_state_effect("idle")
|
||||
|
||||
@subscribe
|
||||
def voice_listen(self, data: dict):
|
||||
self._apply_state_effect("listening")
|
||||
|
||||
@subscribe
|
||||
def voice_thinking(self, data: dict):
|
||||
self._apply_state_effect("thinking")
|
||||
|
||||
@subscribe
|
||||
def voice_responding(self, data: dict):
|
||||
self._apply_state_effect("responding")
|
||||
|
||||
@subscribe
|
||||
def voice_error(self, data: dict):
|
||||
self._apply_state_effect("error")
|
||||
|
||||
@subscribe
|
||||
def mic_muted(self, data: dict):
|
||||
# When muted, show a solid dim red on all backends.
|
||||
self.run_action("solid", _DIM_RED, 1.0)
|
||||
|
||||
@subscribe
|
||||
def mic_unmuted(self, data: dict):
|
||||
self._apply_state_effect("idle")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# MQTT Config Subscriptions
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _update_config(self, state_name: str, data: dict, apply: bool):
|
||||
config = self.configs[state_name]
|
||||
changed = False
|
||||
is_retained = data.get("retained", False)
|
||||
|
||||
new_effect = data.get("effect")
|
||||
if new_effect is not None and config["effect"] != new_effect:
|
||||
config["effect"] = new_effect
|
||||
changed = True
|
||||
|
||||
new_color_data = data.get("color")
|
||||
if new_color_data is not None:
|
||||
new_color_tuple = (
|
||||
new_color_data.get("r"),
|
||||
new_color_data.get("g"),
|
||||
new_color_data.get("b"),
|
||||
)
|
||||
if config["color"] != new_color_tuple:
|
||||
config["color"] = new_color_tuple
|
||||
changed = True
|
||||
|
||||
new_brightness = data.get("brightness")
|
||||
if new_brightness is not None:
|
||||
new_brightness_float = new_brightness / 255.0
|
||||
if abs(config["brightness"] - new_brightness_float) > 0.001:
|
||||
config["brightness"] = new_brightness_float
|
||||
changed = True
|
||||
|
||||
if is_retained:
|
||||
if changed and state_name == "idle":
|
||||
# Re-apply idle but don't republish back to MQTT
|
||||
self._apply_state_effect("idle", publish_state=False)
|
||||
return
|
||||
|
||||
if changed:
|
||||
if apply:
|
||||
self._apply_state_effect(state_name)
|
||||
else:
|
||||
config["state_name"] = state_name
|
||||
self.event_bus.publish("publish_state_to_mqtt", config)
|
||||
|
||||
@subscribe
|
||||
def set_idle_effect(self, data: dict):
|
||||
self._update_config("idle", data, True)
|
||||
|
||||
@subscribe
|
||||
def set_idle_color(self, data: dict):
|
||||
self._update_config(
|
||||
"idle",
|
||||
data,
|
||||
self.configs["idle"]["effect"] != "off",
|
||||
)
|
||||
|
||||
@subscribe
|
||||
def set_listening_effect(self, data: dict):
|
||||
self._update_config("listening", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_listening_color(self, data: dict):
|
||||
self._update_config("listening", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_thinking_effect(self, data: dict):
|
||||
self._update_config("thinking", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_thinking_color(self, data: dict):
|
||||
self._update_config("thinking", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_responding_effect(self, data: dict):
|
||||
self._update_config("responding", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_responding_color(self, data: dict):
|
||||
self._update_config("responding", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_error_effect(self, data: dict):
|
||||
self._update_config("error", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_error_color(self, data: dict):
|
||||
self._update_config("error", data, False)
|
||||
|
||||
@subscribe
|
||||
def set_num_leds(self, data: dict):
|
||||
num_leds = data.get("num_leds")
|
||||
if (num_leds is not None) and (self.num_leds != num_leds):
|
||||
_LOGGER.info(
|
||||
"Number of LEDs changed to %d. Please restart for change to take effect.",
|
||||
num_leds,
|
||||
)
|
||||
@@ -1,272 +0,0 @@
|
||||
import ctypes
|
||||
import json
|
||||
import statistics
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Deque, List, Union
|
||||
|
||||
import numpy as np
|
||||
from pymicro_features import MicroFrontend
|
||||
|
||||
_SAMPLES_PER_SECOND = 16000
|
||||
_SAMPLES_PER_CHUNK = 160 # 10ms
|
||||
_BYTES_PER_SAMPLE = 2 # 16-bit
|
||||
_BYTES_PER_CHUNK = _SAMPLES_PER_CHUNK * _BYTES_PER_SAMPLE
|
||||
_SECONDS_PER_CHUNK = _SAMPLES_PER_CHUNK / _SAMPLES_PER_SECOND
|
||||
_STRIDE = 3
|
||||
_DEFAULT_REFRACTORY = 2 # seconds
|
||||
|
||||
|
||||
class TfLiteQuantizationParams(ctypes.Structure):
|
||||
_fields_ = [("scale", ctypes.c_float), ("zero_point", ctypes.c_int32)]
|
||||
|
||||
|
||||
class MicroWakeWord:
|
||||
def __init__(
|
||||
self,
|
||||
id: str, # pylint: disable=redefined-builtin
|
||||
wake_word: str,
|
||||
tflite_model: Union[str, Path],
|
||||
probability_cutoff: float,
|
||||
sliding_window_size: int,
|
||||
refractory_seconds: float,
|
||||
trained_languages: List[str],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
):
|
||||
self.id = id
|
||||
self.wake_word = wake_word
|
||||
self.tflite_model = tflite_model
|
||||
self.probability_cutoff = probability_cutoff
|
||||
self.sliding_window_size = sliding_window_size
|
||||
self.refractory_seconds = refractory_seconds
|
||||
self.trained_languages = trained_languages
|
||||
|
||||
self.is_active = True
|
||||
|
||||
# Load the shared library
|
||||
self.libtensorflowlite_c_path = Path(libtensorflowlite_c_path)
|
||||
self.lib = ctypes.cdll.LoadLibrary(str(self.libtensorflowlite_c_path.resolve()))
|
||||
|
||||
# Define required argument/return types for C API
|
||||
self.lib.TfLiteModelCreateFromFile.argtypes = [ctypes.c_char_p]
|
||||
self.lib.TfLiteModelCreateFromFile.restype = ctypes.c_void_p
|
||||
|
||||
self.lib.TfLiteInterpreterCreate.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
self.lib.TfLiteInterpreterCreate.restype = ctypes.c_void_p
|
||||
|
||||
self.lib.TfLiteInterpreterAllocateTensors.argtypes = [ctypes.c_void_p]
|
||||
self.lib.TfLiteInterpreterInvoke.argtypes = [ctypes.c_void_p]
|
||||
|
||||
self.lib.TfLiteInterpreterGetInputTensor.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.lib.TfLiteInterpreterGetInputTensor.restype = ctypes.c_void_p
|
||||
|
||||
self.lib.TfLiteInterpreterGetOutputTensor.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.lib.TfLiteInterpreterGetOutputTensor.restype = ctypes.c_void_p
|
||||
|
||||
self.lib.TfLiteTensorCopyFromBuffer.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self.lib.TfLiteTensorCopyToBuffer.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
|
||||
self.lib.TfLiteTensorType.restype = ctypes.c_int
|
||||
|
||||
self.lib.TfLiteTensorByteSize.argtypes = [ctypes.c_void_p]
|
||||
self.lib.TfLiteTensorByteSize.restype = ctypes.c_size_t
|
||||
|
||||
self.lib.TfLiteTensorQuantizationParams.restype = ctypes.Structure
|
||||
|
||||
self.lib.TfLiteTensorQuantizationParams.argtypes = [ctypes.c_void_p]
|
||||
self.lib.TfLiteTensorQuantizationParams.restype = TfLiteQuantizationParams
|
||||
|
||||
# Load the model and create interpreter
|
||||
self.model_path = str(Path(tflite_model).resolve()).encode("utf-8")
|
||||
self._load_model()
|
||||
|
||||
self._frontend = MicroFrontend()
|
||||
self._features: List[np.ndarray] = []
|
||||
self._probabilities: Deque[float] = deque(maxlen=self.sliding_window_size)
|
||||
self._audio_buffer = bytes()
|
||||
self._ignore_seconds: float = 0
|
||||
|
||||
def _load_model(self) -> None:
|
||||
self.model = self.lib.TfLiteModelCreateFromFile(self.model_path)
|
||||
self.interpreter = self.lib.TfLiteInterpreterCreate(self.model, None)
|
||||
self.lib.TfLiteInterpreterAllocateTensors(self.interpreter)
|
||||
|
||||
# Access input and output tensor
|
||||
self.input_tensor = self.lib.TfLiteInterpreterGetInputTensor(
|
||||
self.interpreter, 0
|
||||
)
|
||||
self.output_tensor = self.lib.TfLiteInterpreterGetOutputTensor(
|
||||
self.interpreter, 0
|
||||
)
|
||||
|
||||
# Get quantization parameters
|
||||
input_q = self.lib.TfLiteTensorQuantizationParams(self.input_tensor)
|
||||
output_q = self.lib.TfLiteTensorQuantizationParams(self.output_tensor)
|
||||
|
||||
self.input_scale, self.input_zero_point = input_q.scale, input_q.zero_point
|
||||
self.output_scale, self.output_zero_point = output_q.scale, output_q.zero_point
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reload model and clear state.
|
||||
|
||||
This must be done between audio clips when not streaming.
|
||||
"""
|
||||
self._audio_buffer = bytes()
|
||||
self._features.clear()
|
||||
self._probabilities.clear()
|
||||
self._ignore_seconds = 0
|
||||
|
||||
# Clear out residual features
|
||||
self._frontend = MicroFrontend()
|
||||
|
||||
# Need to reload model to reset intermediary results
|
||||
# reset_all_variables() doesn't work.
|
||||
self._load_model()
|
||||
|
||||
@property
|
||||
def samples_per_chunk(self) -> int:
|
||||
"""Number of samples in a streaming audio chunk."""
|
||||
return _SAMPLES_PER_CHUNK
|
||||
|
||||
@property
|
||||
def bytes_per_chunk(self) -> int:
|
||||
"""Number of bytes in a streaming audio chunk.
|
||||
|
||||
Assumes 16-bit mono samples at 16Khz.
|
||||
"""
|
||||
return _BYTES_PER_CHUNK
|
||||
|
||||
@staticmethod
|
||||
def from_config(
|
||||
config_path: Union[str, Path],
|
||||
libtensorflowlite_c_path: Union[str, Path],
|
||||
refractory_seconds: float = _DEFAULT_REFRACTORY,
|
||||
) -> "MicroWakeWord":
|
||||
"""Load a microWakeWord model from a JSON config file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
config_path: str or Path
|
||||
Path to JSON configuration file
|
||||
refractory_seconds: float
|
||||
Number of seconds to ignore after detection
|
||||
"""
|
||||
config_path = Path(config_path)
|
||||
with open(config_path, "r", encoding="utf-8") as config_file:
|
||||
config = json.load(config_file)
|
||||
|
||||
micro_config = config["micro"]
|
||||
|
||||
return MicroWakeWord(
|
||||
id=Path(config["model"]).stem,
|
||||
wake_word=config["wake_word"],
|
||||
tflite_model=config_path.parent / config["model"],
|
||||
probability_cutoff=micro_config["probability_cutoff"],
|
||||
sliding_window_size=micro_config["sliding_window_size"],
|
||||
refractory_seconds=refractory_seconds,
|
||||
trained_languages=micro_config.get("trained_languages", []),
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
def process_streaming(self, audio_bytes: bytes) -> bool:
|
||||
"""Process a chunk of audio in streaming mode.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
audio_bytes: bytes
|
||||
Raw 16-bit mono audio samples at 16Khz
|
||||
|
||||
Returns True if the wake word was detected.
|
||||
"""
|
||||
self._audio_buffer += audio_bytes
|
||||
|
||||
if len(self._audio_buffer) < _BYTES_PER_CHUNK:
|
||||
# Not enough audio to get features
|
||||
return False
|
||||
|
||||
detected = False
|
||||
audio_buffer_idx = 0
|
||||
while (audio_buffer_idx + _BYTES_PER_CHUNK) <= len(self._audio_buffer):
|
||||
# Process chunk
|
||||
chunk_bytes = self._audio_buffer[
|
||||
audio_buffer_idx : audio_buffer_idx + _BYTES_PER_CHUNK
|
||||
]
|
||||
frontend_result = self._frontend.ProcessSamples(chunk_bytes)
|
||||
audio_buffer_idx += frontend_result.samples_read * _BYTES_PER_SAMPLE
|
||||
self._ignore_seconds = max(0, self._ignore_seconds - _SECONDS_PER_CHUNK)
|
||||
|
||||
if not frontend_result.features:
|
||||
# Not enough audio for a full window
|
||||
continue
|
||||
|
||||
self._features.append(
|
||||
np.array(frontend_result.features).reshape(
|
||||
(1, 1, len(frontend_result.features))
|
||||
)
|
||||
)
|
||||
|
||||
if len(self._features) < _STRIDE:
|
||||
# Not enough windows
|
||||
continue
|
||||
|
||||
# Allocate and quantize input data
|
||||
quant_features = np.round(
|
||||
np.concatenate(self._features, axis=1) / self.input_scale
|
||||
+ self.input_zero_point
|
||||
).astype(np.uint8)
|
||||
|
||||
# Stride instead of rolling
|
||||
self._features.clear()
|
||||
|
||||
# Set tensor
|
||||
quant_ptr = quant_features.ctypes.data_as(ctypes.c_void_p)
|
||||
self.lib.TfLiteTensorCopyFromBuffer(
|
||||
self.input_tensor, quant_ptr, quant_features.nbytes
|
||||
)
|
||||
|
||||
# Run inference
|
||||
self.lib.TfLiteInterpreterInvoke(self.interpreter)
|
||||
|
||||
# Read output
|
||||
output_bytes = self.lib.TfLiteTensorByteSize(self.output_tensor)
|
||||
output_data = np.empty(output_bytes, dtype=np.uint8)
|
||||
self.lib.TfLiteTensorCopyToBuffer(
|
||||
self.output_tensor,
|
||||
output_data.ctypes.data_as(ctypes.c_void_p),
|
||||
output_bytes,
|
||||
)
|
||||
|
||||
# Dequantize output
|
||||
result = (
|
||||
output_data.astype(np.float32) - self.output_zero_point
|
||||
) * self.output_scale
|
||||
|
||||
self._probabilities.append(result.item())
|
||||
|
||||
if len(self._probabilities) < self.sliding_window_size:
|
||||
# Not enough probabilities
|
||||
continue
|
||||
|
||||
if statistics.mean(self._probabilities) > self.probability_cutoff:
|
||||
if self._ignore_seconds <= 0:
|
||||
detected = True
|
||||
self._ignore_seconds = self.refractory_seconds
|
||||
|
||||
# Remove processed audio
|
||||
self._audio_buffer = self._audio_buffer[audio_buffer_idx:]
|
||||
|
||||
return detected
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Shared models."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
|
||||
|
||||
from .event_bus import EventBus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
from .entity import ESPHomeEntity
|
||||
from .mpv_player import MpvMediaPlayer
|
||||
from .satellite import VoiceSatelliteProtocol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SatelliteState(str, Enum):
|
||||
"""Voice satellite state."""
|
||||
STARTING = "starting"
|
||||
IDLE = "idle"
|
||||
LISTENING = "listening"
|
||||
THINKING = "thinking"
|
||||
RESPONDING = "responding"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class WakeWordType(str, Enum):
|
||||
MICRO_WAKE_WORD = "micro"
|
||||
OPEN_WAKE_WORD = "openWakeWord"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AvailableWakeWord:
|
||||
id: str
|
||||
type: WakeWordType
|
||||
wake_word: str
|
||||
trained_languages: List[str]
|
||||
wake_word_path: Path
|
||||
|
||||
def load(self) -> "Union[MicroWakeWord, OpenWakeWord]":
|
||||
if self.type == WakeWordType.MICRO_WAKE_WORD:
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
return MicroWakeWord.from_config(config_path=self.wake_word_path)
|
||||
|
||||
if self.type == WakeWordType.OPEN_WAKE_WORD:
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
oww_model = OpenWakeWord.from_model(model_path=self.wake_word_path)
|
||||
setattr(oww_model, "wake_word", self.wake_word)
|
||||
return oww_model
|
||||
|
||||
raise ValueError(f"Unexpected wake word type: {self.type}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Preferences:
|
||||
active_wake_words: List[str] = field(default_factory=list)
|
||||
volume_level: float = 1.0
|
||||
num_leds: int = 3
|
||||
# New: configurable alarm duration in seconds.
|
||||
# 0 = infinite alarm (only Stop/wake word stops it)
|
||||
# >0 = auto-stop alarm after this many seconds
|
||||
alarm_duration_seconds: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerState:
|
||||
"""A simple dataclass to hold core application state."""
|
||||
# --- Fields WITHOUT default values ---
|
||||
name: str
|
||||
mac_address: str
|
||||
event_bus: EventBus
|
||||
loop: asyncio.AbstractEventLoop
|
||||
entities: "List[ESPHomeEntity]"
|
||||
music_player: "MpvMediaPlayer"
|
||||
tts_player: "MpvMediaPlayer"
|
||||
available_wake_words: "Dict[str, AvailableWakeWord]"
|
||||
wake_words: "Dict[str, Union[MicroWakeWord, OpenWakeWord]]"
|
||||
active_wake_words: Set[str]
|
||||
stop_word: "MicroWakeWord"
|
||||
wakeup_sound: str
|
||||
timer_finished_sound: str
|
||||
preferences_path: Path
|
||||
download_dir: Path
|
||||
preferences: Preferences
|
||||
|
||||
# --- Fields WITH default values ---
|
||||
satellite: "Optional[VoiceSatelliteProtocol]" = None
|
||||
wake_words_changed: bool = False
|
||||
refractory_seconds: float = 2.0
|
||||
mic_muted: bool = False
|
||||
shutdown: bool = False
|
||||
|
||||
# Threading event to pause the audio thread efficiently when muted
|
||||
# set() = Mic is ON (Audio processing running)
|
||||
# clear() = Mic is OFF (Audio processing paused)
|
||||
mic_muted_event: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure the threading event matches the boolean state on init."""
|
||||
if not self.mic_muted:
|
||||
self.mic_muted_event.set()
|
||||
else:
|
||||
self.mic_muted_event.clear()
|
||||
|
||||
def save_preferences(self) -> None:
|
||||
"""Save preferences as JSON."""
|
||||
_LOGGER.debug("Saving preferences: %s", self.preferences_path)
|
||||
self.preferences_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.preferences_path, "w", encoding="utf-8") as preferences_file:
|
||||
json.dump(
|
||||
asdict(self.preferences),
|
||||
preferences_file,
|
||||
ensure_ascii=False,
|
||||
indent=4,
|
||||
)
|
||||
@@ -1,94 +1,277 @@
|
||||
"""Media player using mpv in a subprocess."""
|
||||
"""Media player using mpv in a subprocess.
|
||||
|
||||
This wrapper focuses on:
|
||||
- Simple playback control (play / pause / resume / stop)
|
||||
- Volume control and ducking
|
||||
- Notifying a done_callback when playback finishes
|
||||
- Letting mpv choose the best audio backend by default
|
||||
|
||||
If a specific audio device is provided, it is passed directly to mpv as
|
||||
`audio-device`. Otherwise, mpv's own automatic backend/device selection is used.
|
||||
|
||||
Note about volume:
|
||||
- mpv has its own per-player volume (0..100).
|
||||
- PipeWire/PulseAudio/ALSA also has a system output volume.
|
||||
|
||||
LVA persists a single user volume (0.0..1.0) in preferences.json. To avoid
|
||||
"mystery caps" where the OS sink is stuck at e.g. 40% while LVA shows 100%,
|
||||
LVA treats the OS output volume as the "master" and keeps mpv at 100% for normal
|
||||
playback. Ducking still uses mpv's per-player volume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Union
|
||||
from typing import Callable, List, Optional, Sequence, Union
|
||||
|
||||
# Note: python-mpv must be installed; imported at runtime.
|
||||
from mpv import MPV
|
||||
|
||||
from .audio_volume import set_output_volume
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MpvMediaPlayer:
|
||||
def __init__(self, device: Optional[str] = None) -> None:
|
||||
self.player = MPV()
|
||||
"""A media player class that wraps the python-mpv library."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop: Optional[asyncio.AbstractEventLoop],
|
||||
device: Optional[str] = None,
|
||||
initial_volume: float = 1.0,
|
||||
) -> None:
|
||||
"""Initialize the mpv player.
|
||||
|
||||
:param loop: The asyncio loop used to schedule done_callback.
|
||||
May be None in one-off utility contexts (e.g. listing devices).
|
||||
:param device: Optional mpv audio device name (e.g.
|
||||
"pipewire/alsa_output.pci-0000_00_1f.3.analog-stereo").
|
||||
:param initial_volume: Initial volume as a float 0.0–1.0. This is now
|
||||
treated as the *master* OS output volume. mpv's
|
||||
internal volume is set to 100% for normal playback.
|
||||
"""
|
||||
self.loop = loop
|
||||
self.device = device
|
||||
self.initial_volume = max(0.0, min(1.0, float(initial_volume)))
|
||||
|
||||
self.player = MPV(
|
||||
video=False,
|
||||
terminal=False,
|
||||
log_handler=self._mpv_log,
|
||||
audio_samplerate=44100,
|
||||
audio_channels="stereo",
|
||||
keep_open="no",
|
||||
network_timeout=7,
|
||||
msg_level=os.environ.get("LVA_MPV_MSG_LEVEL", "all=warn"),
|
||||
)
|
||||
|
||||
# Optional: allow forcing ao via environment for power users/debugging.
|
||||
ao_env = os.environ.get("LVA_AO")
|
||||
if ao_env:
|
||||
try:
|
||||
self.player["ao"] = ao_env
|
||||
_LOGGER.info("Forcing mpv ao=%r from LVA_AO", ao_env)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to set mpv ao=%r", ao_env)
|
||||
|
||||
# If the caller provided a specific device, honor it directly.
|
||||
if device:
|
||||
self.player["audio-device"] = device
|
||||
try:
|
||||
self.player["audio-device"] = device
|
||||
_LOGGER.info("Using mpv audio-device=%r", device)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to set mpv audio-device %r", device)
|
||||
|
||||
self.is_playing = False
|
||||
# Log final backend/device selection and available devices (best-effort)
|
||||
try:
|
||||
ao_effective = self.player["ao"]
|
||||
# python-mpv may expose an unset option as [] – normal for "auto"
|
||||
if isinstance(ao_effective, list) and not ao_effective:
|
||||
ao_display = "<unset/auto>"
|
||||
else:
|
||||
ao_display = ao_effective
|
||||
|
||||
self._playlist: List[str] = []
|
||||
audio_device_effective = self.player["audio-device"]
|
||||
|
||||
try:
|
||||
dev_list = self.player.audio_device_list or []
|
||||
dev_summary = [
|
||||
f"{dev.get('name')} ({dev.get('description')})" for dev in dev_list
|
||||
]
|
||||
except Exception:
|
||||
dev_summary = ["<unavailable>"]
|
||||
|
||||
_LOGGER.debug(
|
||||
"mpv audio config: ao=%r, audio-device=%r, devices=%s",
|
||||
ao_display,
|
||||
audio_device_effective,
|
||||
dev_summary,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to query mpv audio properties")
|
||||
|
||||
# Keep mpv at 100% for normal playback. Master volume is handled at the OS level.
|
||||
self.set_volume(100)
|
||||
|
||||
self.is_playing: bool = False
|
||||
self._done_callback: Optional[Callable[[], None]] = None
|
||||
self._done_callback_lock = Lock()
|
||||
self._pre_duck_volume: Optional[int] = None
|
||||
|
||||
self._duck_volume: int = 50
|
||||
self._unduck_volume: int = 100
|
||||
# When mpv becomes idle, we treat it as end-of-playback.
|
||||
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,
|
||||
) -> None:
|
||||
"""Plays a URL or sequence of URLs.
|
||||
|
||||
:param url: A single URL (str/bytes) or a sequence of URLs.
|
||||
:param done_callback: Called once when playback finishes or is stopped.
|
||||
"""
|
||||
# Ensure player is in a clean state
|
||||
self.stop()
|
||||
|
||||
if isinstance(url, str):
|
||||
self._playlist = [url]
|
||||
with self._done_callback_lock:
|
||||
self._done_callback = done_callback
|
||||
|
||||
playlist: List[str] = []
|
||||
if isinstance(url, (list, tuple)):
|
||||
playlist = list(url)
|
||||
elif isinstance(url, bytes):
|
||||
playlist = [url.decode(errors="ignore")]
|
||||
elif isinstance(url, str):
|
||||
playlist = [url]
|
||||
else:
|
||||
self._playlist = url
|
||||
|
||||
next_url = self._playlist.pop(0)
|
||||
_LOGGER.debug("Playing %s", next_url)
|
||||
|
||||
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
|
||||
|
||||
def resume(self) -> None:
|
||||
self.player.pause = False
|
||||
if self._playlist:
|
||||
self.is_playing = True
|
||||
|
||||
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))
|
||||
_LOGGER.error("play() expected str, bytes, or sequence, got %r", type(url))
|
||||
self._run_done_callback()
|
||||
return
|
||||
|
||||
self.is_playing = False
|
||||
if not playlist:
|
||||
self._run_done_callback()
|
||||
return
|
||||
|
||||
todo_callback: Optional[Callable[[], None]] = None
|
||||
with self._done_callback_lock:
|
||||
if self._done_callback:
|
||||
todo_callback = self._done_callback
|
||||
self._done_callback = None
|
||||
# Load the full playlist into mpv
|
||||
self.player.playlist_clear()
|
||||
for item in playlist:
|
||||
self.player.playlist_append(item)
|
||||
|
||||
if todo_callback:
|
||||
self.is_playing = True
|
||||
self.player.playlist_pos = 0 # Start playing from the first item
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pauses playback."""
|
||||
try:
|
||||
self.player.pause = True
|
||||
except Exception:
|
||||
_LOGGER.exception("pause() failed")
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resumes playback."""
|
||||
try:
|
||||
self.player.pause = False
|
||||
except Exception:
|
||||
_LOGGER.exception("resume() failed")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stops playback and clears the playlist."""
|
||||
if self.is_playing:
|
||||
self.is_playing = False
|
||||
try:
|
||||
todo_callback()
|
||||
self.player.playlist_clear()
|
||||
self.player.command("stop")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error running done callback")
|
||||
_LOGGER.exception("stop() failed")
|
||||
finally:
|
||||
self._run_done_callback()
|
||||
|
||||
def set_volume(self, volume: int) -> None:
|
||||
"""Sets the player (mpv) volume from 0 to 100."""
|
||||
try:
|
||||
self.player.volume = max(0, min(100, volume))
|
||||
except Exception:
|
||||
_LOGGER.exception("set_volume() failed")
|
||||
|
||||
def set_master_volume(self, volume_level: float) -> bool:
|
||||
"""Sets the *OS output* volume (PipeWire/PulseAudio/ALSA).
|
||||
|
||||
:param volume_level: 0.0–1.0
|
||||
:returns: True if any backend succeeded.
|
||||
"""
|
||||
return set_output_volume(volume_level=volume_level, output_device=self.device)
|
||||
|
||||
def duck(self, target_percent: int = 20) -> None:
|
||||
"""Lowers the mpv volume for an announcement."""
|
||||
if self._pre_duck_volume is not None:
|
||||
return
|
||||
try:
|
||||
self._pre_duck_volume = int(self.player.volume)
|
||||
self.set_volume(target_percent)
|
||||
except Exception:
|
||||
_LOGGER.exception("duck() failed")
|
||||
|
||||
def unduck(self) -> None:
|
||||
"""Restores the mpv volume after an announcement."""
|
||||
if self._pre_duck_volume is None:
|
||||
return
|
||||
try:
|
||||
self.set_volume(self._pre_duck_volume)
|
||||
except Exception:
|
||||
_LOGGER.exception("unduck() failed")
|
||||
finally:
|
||||
self._pre_duck_volume = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal callbacks
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _on_idle_active(self, _name: str, active: bool) -> None:
|
||||
"""Callback triggered when mpv enters or leaves the idle state."""
|
||||
if active and self.is_playing:
|
||||
_LOGGER.debug("mpv became idle; treating as end-of-playback")
|
||||
self.is_playing = False
|
||||
self._run_done_callback()
|
||||
|
||||
def _run_done_callback(self) -> None:
|
||||
"""Safely runs the done_callback on the main asyncio loop (if any)."""
|
||||
with self._done_callback_lock:
|
||||
cb = self._done_callback
|
||||
self._done_callback = None
|
||||
|
||||
if not cb:
|
||||
return
|
||||
|
||||
# If we have an asyncio loop, schedule callback there.
|
||||
if self.loop is not None:
|
||||
try:
|
||||
self.loop.call_soon_threadsafe(cb)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error scheduling done_callback on loop")
|
||||
else:
|
||||
# Fallback: call directly (used in contexts where no loop is passed).
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error running done_callback directly")
|
||||
|
||||
def _mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
"""Routes mpv's internal logs to our logger."""
|
||||
msg = f"mpv[{prefix}]: {text}".rstrip()
|
||||
if level == "error":
|
||||
_LOGGER.error(msg)
|
||||
elif level == "warn":
|
||||
_LOGGER.warning(msg)
|
||||
elif level == "info":
|
||||
_LOGGER.info(msg)
|
||||
else:
|
||||
_LOGGER.debug(msg)
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from .config import MqttConfig
|
||||
from .event_bus import EventBus, EventHandler, subscribe
|
||||
from .models import Preferences
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MqttController(EventHandler):
|
||||
def __init__(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
event_bus: EventBus,
|
||||
config: MqttConfig,
|
||||
app_name: str,
|
||||
mac_address: str,
|
||||
preferences: Preferences,
|
||||
):
|
||||
super().__init__(event_bus)
|
||||
self.loop = loop
|
||||
self.preferences = preferences
|
||||
|
||||
self._host = config.host
|
||||
self._port = config.port
|
||||
self._username = config.username
|
||||
self._password = config.password
|
||||
|
||||
self._device_name = app_name
|
||||
self._device_id = app_name.lower().replace(" ", "_")
|
||||
self._topic_prefix = f"lva/{self._device_id}"
|
||||
|
||||
self._is_muted = False # Internal state
|
||||
|
||||
self.CONFIGURABLE_STATES: List[str] = [
|
||||
"idle",
|
||||
"listening",
|
||||
"thinking",
|
||||
"responding",
|
||||
"error",
|
||||
]
|
||||
self.topics = {
|
||||
"mute": {
|
||||
"command": f"{self._topic_prefix}/mute/set",
|
||||
"state": f"{self._topic_prefix}/mute/state",
|
||||
},
|
||||
"num_leds": {
|
||||
"command": f"{self._topic_prefix}/num_leds/set",
|
||||
"state": f"{self._topic_prefix}/num_leds/state",
|
||||
},
|
||||
# New: alarm duration
|
||||
"alarm_duration": {
|
||||
"command": f"{self._topic_prefix}/alarm_duration/set",
|
||||
"state": f"{self._topic_prefix}/alarm_duration/state",
|
||||
},
|
||||
}
|
||||
for state_name in self.CONFIGURABLE_STATES:
|
||||
self.topics[state_name] = {
|
||||
"effect_command": f"{self._topic_prefix}/{state_name}_effect/set",
|
||||
"effect_state": f"{self._topic_prefix}/{state_name}_effect/state",
|
||||
"light_command": f"{self._topic_prefix}/{state_name}_light/set",
|
||||
"light_state": f"{self._topic_prefix}/{state_name}_light/state",
|
||||
}
|
||||
|
||||
# During startup we accept retained */state topics from the broker to restore
|
||||
# Home Assistant's last-known settings. After bootstrap, we ignore */state topics
|
||||
# to avoid feedback loops (we only accept */set as commands).
|
||||
self._bootstrap_state_sync = True
|
||||
self._bootstrap_ends_at: Optional[float] = None
|
||||
|
||||
self._client = mqtt.Client()
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_message = self._on_message
|
||||
|
||||
# Subscribe to events *after* __init__ is complete
|
||||
self._subscribe_all_methods()
|
||||
|
||||
def start(self):
|
||||
try:
|
||||
if self._username:
|
||||
self._client.username_pw_set(self._username, self._password)
|
||||
_LOGGER.debug(
|
||||
"Connecting to MQTT broker at %s:%s", self._host, self._port
|
||||
)
|
||||
self._client.connect(self._host, self._port, 60)
|
||||
self._client.loop_start()
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to connect to MQTT broker")
|
||||
|
||||
def stop(self):
|
||||
self._client.publish(
|
||||
f"{self._topic_prefix}/availability", "offline", retain=True
|
||||
)
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
_LOGGER.debug("Disconnected from MQTT broker")
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
_LOGGER.info("Connected to MQTT broker")
|
||||
client.subscribe(f"{self._topic_prefix}/+/set")
|
||||
client.subscribe(f"{self._topic_prefix}/+/state")
|
||||
# End bootstrap a moment after subscribe so retained state messages can arrive.
|
||||
self._bootstrap_ends_at = self.loop.time() + 2.0
|
||||
self.loop.call_later(2.0, self._end_bootstrap_state_sync)
|
||||
self._publish_discovery_configs()
|
||||
else:
|
||||
_LOGGER.error("Failed to connect to MQTT, return code %d", rc)
|
||||
|
||||
def _end_bootstrap_state_sync(self):
|
||||
# Called on the asyncio loop thread.
|
||||
self._bootstrap_state_sync = False
|
||||
# We only needed */state subscriptions to ingest retained settings at startup.
|
||||
# After bootstrap, unsubscribe to avoid feedback loops and log spam.
|
||||
try:
|
||||
self._client.unsubscribe(f"{self._topic_prefix}/+/state")
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to unsubscribe from */state topics", exc_info=True)
|
||||
_LOGGER.debug(
|
||||
"MQTT bootstrap state sync complete; unsubscribed from */state and will ignore any state topics"
|
||||
)
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode()
|
||||
_LOGGER.debug(
|
||||
"Received MQTT message on topic %s (from MQTT thread)", msg.topic
|
||||
)
|
||||
retain = bool(getattr(msg, "retain", False))
|
||||
self.loop.call_soon_threadsafe(
|
||||
self._handle_message_on_loop, topic, payload, retain
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Error in _on_message")
|
||||
|
||||
def _handle_message_on_loop(self, topic: str, payload: str, retained: bool = False):
|
||||
_LOGGER.debug("Handling message for topic %s on main loop", topic)
|
||||
|
||||
# Prevent feedback loops: we only treat */set as commands.
|
||||
# However during bootstrap we accept retained */state values to restore HA settings.
|
||||
if topic.endswith("/state"):
|
||||
if not (self._bootstrap_state_sync and retained):
|
||||
_LOGGER.debug("Ignoring MQTT state topic: %s (retained=%s, bootstrap=%s)", topic, retained, self._bootstrap_state_sync)
|
||||
return
|
||||
|
||||
|
||||
if topic == self.topics["mute"]["command"]:
|
||||
# Publish event for MicMuteHandler to process
|
||||
self.event_bus.publish(
|
||||
"set_mic_mute", {"state": payload.upper() == "ON"}
|
||||
)
|
||||
|
||||
elif topic == self.topics["num_leds"]["command"]:
|
||||
try:
|
||||
num_leds = int(payload)
|
||||
# Publish event for LedController and MicMuteHandler (to save)
|
||||
self.event_bus.publish("set_num_leds", {"num_leds": num_leds})
|
||||
self.publish_num_leds_state(num_leds)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif topic == self.topics["alarm_duration"]["command"]:
|
||||
try:
|
||||
duration = int(payload)
|
||||
if duration < 0:
|
||||
raise ValueError
|
||||
# Publish event for MicMuteHandler (to save preferences)
|
||||
self.event_bus.publish(
|
||||
"set_alarm_duration",
|
||||
{"alarm_duration_seconds": duration},
|
||||
)
|
||||
self.publish_alarm_duration_state(duration)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Invalid alarm_duration payload received: %r", payload
|
||||
)
|
||||
|
||||
for state_name in self.CONFIGURABLE_STATES:
|
||||
state_topics = self.topics[state_name]
|
||||
if topic == state_topics["effect_command"]:
|
||||
effect_id = payload.lower().replace(" ", "_")
|
||||
self.event_bus.publish(
|
||||
f"set_{state_name}_effect", {"effect": effect_id}
|
||||
)
|
||||
|
||||
elif topic == state_topics["light_command"]:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
if "state" in data:
|
||||
if data["state"].upper() == "OFF":
|
||||
self.event_bus.publish(
|
||||
f"set_{state_name}_effect", {"effect": "off"}
|
||||
)
|
||||
else: # ON command
|
||||
self.event_bus.publish(f"turn_on_{state_name}")
|
||||
|
||||
if "color" in data or "brightness" in data:
|
||||
self.event_bus.publish(
|
||||
f"set_{state_name}_color", data
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
elif topic == state_topics["effect_state"]:
|
||||
effect_id = payload.lower().replace(" ", "_")
|
||||
self.event_bus.publish(
|
||||
f"set_{state_name}_effect",
|
||||
{"effect": effect_id, "retained": retained},
|
||||
)
|
||||
elif topic == state_topics["light_state"]:
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
data["retained"] = retained
|
||||
self.event_bus.publish(
|
||||
f"set_{state_name}_color", data
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
def _publish_discovery_configs(self):
|
||||
availability_topic = f"{self._topic_prefix}/availability"
|
||||
device_info = {
|
||||
"identifiers": [self._device_id],
|
||||
"name": self._device_name,
|
||||
"manufacturer": "LVA Project",
|
||||
}
|
||||
options = [
|
||||
"Off",
|
||||
"Solid",
|
||||
"Slow Pulse",
|
||||
"Medium Pulse",
|
||||
"Fast Pulse",
|
||||
"Slow Blink",
|
||||
"Medium Blink",
|
||||
"Fast Blink",
|
||||
"Spin",
|
||||
]
|
||||
|
||||
mute_cfg = {
|
||||
"name": "Mute Microphone",
|
||||
"unique_id": f"{self._device_id}_mute",
|
||||
"command_topic": self.topics["mute"]["command"],
|
||||
"state_topic": self.topics["mute"]["state"],
|
||||
"availability_topic": availability_topic,
|
||||
"icon": "mdi:microphone-off",
|
||||
"device": device_info,
|
||||
}
|
||||
self._client.publish(
|
||||
f"homeassistant/switch/{self._device_id}_mute/config",
|
||||
json.dumps(mute_cfg),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
num_leds_cfg = {
|
||||
"name": "Number of LEDs",
|
||||
"unique_id": f"{self._device_id}_num_leds",
|
||||
"command_topic": self.topics["num_leds"]["command"],
|
||||
"state_topic": self.topics["num_leds"]["state"],
|
||||
"availability_topic": availability_topic,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 1,
|
||||
"icon": "mdi:counter",
|
||||
"device": device_info,
|
||||
"mode": "box",
|
||||
"entity_category": "config",
|
||||
}
|
||||
self._client.publish(
|
||||
f"homeassistant/number/{self._device_id}_num_leds/config",
|
||||
json.dumps(num_leds_cfg),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
# New: Alarm Duration number (seconds)
|
||||
alarm_duration_cfg = {
|
||||
"name": "Alarm Duration",
|
||||
"unique_id": f"{self._device_id}_alarm_duration",
|
||||
"command_topic": self.topics["alarm_duration"]["command"],
|
||||
"state_topic": self.topics["alarm_duration"]["state"],
|
||||
"availability_topic": availability_topic,
|
||||
"min": 0,
|
||||
"max": 3600,
|
||||
"step": 5,
|
||||
"unit_of_measurement": "s",
|
||||
"icon": "mdi:timer",
|
||||
"device": device_info,
|
||||
"mode": "box",
|
||||
"entity_category": "config",
|
||||
}
|
||||
self._client.publish(
|
||||
f"homeassistant/number/{self._device_id}_alarm_duration/config",
|
||||
json.dumps(alarm_duration_cfg),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
for state_name in self.CONFIGURABLE_STATES:
|
||||
capital_name = state_name.title()
|
||||
|
||||
select_cfg = {
|
||||
"name": f"{capital_name} Effect",
|
||||
"unique_id": f"{self._device_id}_{state_name}_effect",
|
||||
"command_topic": self.topics[state_name]["effect_command"],
|
||||
"state_topic": self.topics[state_name]["effect_state"],
|
||||
"availability_topic": availability_topic,
|
||||
"options": options,
|
||||
"icon": "mdi:palette-swatch-variant",
|
||||
"device": device_info,
|
||||
"entity_category": "config",
|
||||
}
|
||||
self._client.publish(
|
||||
f"homeassistant/select/{self._device_id}_{state_name}_effect/config",
|
||||
json.dumps(select_cfg),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
light_cfg = {
|
||||
"name": f"{capital_name} Color",
|
||||
"unique_id": f"{self._device_id}_{state_name}_color",
|
||||
"schema": "json",
|
||||
"command_topic": self.topics[state_name]["light_command"],
|
||||
"state_topic": self.topics[state_name]["light_state"],
|
||||
"availability_topic": availability_topic,
|
||||
"brightness": True,
|
||||
"color_mode": True,
|
||||
"supported_color_modes": ["rgb"],
|
||||
"device": device_info,
|
||||
"entity_category": "config",
|
||||
}
|
||||
self._client.publish(
|
||||
f"homeassistant/light/{self._device_id}_{state_name}_color/config",
|
||||
json.dumps(light_cfg),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Published all MQTT discovery configs")
|
||||
self._client.publish(availability_topic, "online", retain=True)
|
||||
|
||||
# Publish initial state from internal values
|
||||
self.publish_mute_state(self._is_muted)
|
||||
self.publish_num_leds_state(self.preferences.num_leds)
|
||||
self.publish_alarm_duration_state(
|
||||
getattr(self.preferences, "alarm_duration_seconds", 0)
|
||||
)
|
||||
|
||||
def publish_mute_state(self, is_muted: bool):
|
||||
self._is_muted = is_muted
|
||||
self._client.publish(
|
||||
self.topics["mute"]["state"],
|
||||
"ON" if is_muted else "OFF",
|
||||
retain=True,
|
||||
)
|
||||
|
||||
def publish_num_leds_state(self, num_leds: int):
|
||||
self._client.publish(
|
||||
self.topics["num_leds"]["state"], str(num_leds), retain=True
|
||||
)
|
||||
|
||||
def publish_alarm_duration_state(self, duration_seconds: int):
|
||||
self._client.publish(
|
||||
self.topics["alarm_duration"]["state"],
|
||||
str(int(duration_seconds)),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
@subscribe
|
||||
def publish_state_to_mqtt(self, data: dict):
|
||||
state_name = data.get("state_name")
|
||||
if state_name in self.topics:
|
||||
state_topics = self.topics[state_name]
|
||||
|
||||
effect_name = data.get("effect", "off").replace("_", " ").title()
|
||||
self._client.publish(
|
||||
state_topics["effect_state"], effect_name, retain=True
|
||||
)
|
||||
|
||||
light_state = {
|
||||
"state": "ON" if data.get("effect") != "off" else "OFF",
|
||||
"color_mode": "rgb",
|
||||
"brightness": int(data.get("brightness", 0.5) * 255),
|
||||
"color": {
|
||||
"r": data.get("color")[0],
|
||||
"g": data.get("color")[1],
|
||||
"b": data.get("color")[2],
|
||||
},
|
||||
}
|
||||
self._client.publish(
|
||||
state_topics["light_state"],
|
||||
json.dumps(light_state),
|
||||
retain=True,
|
||||
)
|
||||
|
||||
# --- Listen for state changes from other components ---
|
||||
|
||||
@subscribe
|
||||
def mic_muted(self, data: dict):
|
||||
self.publish_mute_state(True)
|
||||
|
||||
@subscribe
|
||||
def mic_unmuted(self, data: dict):
|
||||
self.publish_mute_state(False)
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Voice satellite protocol."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
import posixpath
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Set, Union, List
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
DeviceInfoRequest,
|
||||
DeviceInfoResponse,
|
||||
ListEntitiesDoneResponse,
|
||||
ListEntitiesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
VoiceAssistantAnnounceFinished,
|
||||
VoiceAssistantAnnounceRequest,
|
||||
VoiceAssistantAudio,
|
||||
VoiceAssistantConfigurationRequest,
|
||||
VoiceAssistantConfigurationResponse,
|
||||
VoiceAssistantEventResponse,
|
||||
VoiceAssistantExternalWakeWord,
|
||||
VoiceAssistantRequest,
|
||||
VoiceAssistantSetConfiguration,
|
||||
VoiceAssistantTimerEventResponse,
|
||||
VoiceAssistantWakeWord,
|
||||
)
|
||||
from aioesphomeapi.model import (
|
||||
VoiceAssistantEventType,
|
||||
VoiceAssistantFeature,
|
||||
VoiceAssistantTimerEventType,
|
||||
)
|
||||
from google.protobuf import message
|
||||
from pymicro_wakeword import MicroWakeWord
|
||||
from pyopen_wakeword import OpenWakeWord
|
||||
|
||||
from .api_server import APIServer
|
||||
from .entity import MediaPlayerEntity
|
||||
from .models import AvailableWakeWord, ServerState, SatelliteState, WakeWordType
|
||||
from .util import call_all
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VoiceSatelliteProtocol(APIServer):
|
||||
def __init__(self, state: ServerState) -> None:
|
||||
super().__init__(state.name)
|
||||
self.state = state
|
||||
self.state.satellite = self
|
||||
|
||||
# --- Ensure exactly one MediaPlayerEntity with a stable key ---
|
||||
if self.state.entities:
|
||||
existing = self.state.entities[0]
|
||||
if not isinstance(existing, MediaPlayerEntity):
|
||||
_LOGGER.warning(
|
||||
"First ESPHome entity is not MediaPlayerEntity (%r). "
|
||||
"Replacing it with a new MediaPlayerEntity.",
|
||||
type(existing),
|
||||
)
|
||||
self.media_player_entity = MediaPlayerEntity(
|
||||
server=self,
|
||||
state=state,
|
||||
key=0,
|
||||
name="Media Player",
|
||||
object_id="linux_voice_assistant_media_player",
|
||||
music_player=state.music_player,
|
||||
announce_player=state.tts_player,
|
||||
)
|
||||
self.state.entities[0] = self.media_player_entity
|
||||
else:
|
||||
# Reuse existing entity but bind it to the current protocol
|
||||
self.media_player_entity = existing
|
||||
self.media_player_entity.server = self
|
||||
self.media_player_entity.key = 0
|
||||
else:
|
||||
# First connection in this process: create the media player once
|
||||
self.media_player_entity = MediaPlayerEntity(
|
||||
server=self,
|
||||
state=state,
|
||||
key=0,
|
||||
name="Media Player",
|
||||
object_id="linux_voice_assistant_media_player",
|
||||
music_player=state.music_player,
|
||||
announce_player=state.tts_player,
|
||||
)
|
||||
self.state.entities.append(self.media_player_entity)
|
||||
|
||||
# If more entities somehow accumulated, prune them to avoid confusing HA
|
||||
if len(self.state.entities) > 1:
|
||||
_LOGGER.warning(
|
||||
"Pruning %d extra ESPHome entities; keeping only the first.",
|
||||
len(self.state.entities) - 1,
|
||||
)
|
||||
del self.state.entities[1:]
|
||||
|
||||
# State machine
|
||||
self._state: SatelliteState = SatelliteState.STARTING
|
||||
self._is_streaming_audio: bool = False
|
||||
|
||||
self._tts_url: Optional[str] = None
|
||||
self._continue_conversation: bool = False
|
||||
self._timer_finished: bool = False
|
||||
|
||||
self._run_end_received: bool = False
|
||||
self._tts_end_received: bool = False
|
||||
# Track if current audio is an announcement
|
||||
self._is_announcement: bool = False
|
||||
|
||||
# External wake words announced by Home Assistant
|
||||
self._external_wake_words: Dict[str, VoiceAssistantExternalWakeWord] = {}
|
||||
|
||||
# Timer alarm auto-stop handle
|
||||
self._timer_auto_stop_handle: Optional[asyncio.TimerHandle] = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# State machine helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _set_state(self, new_state: SatelliteState):
|
||||
if self._state == new_state:
|
||||
return
|
||||
|
||||
_LOGGER.debug("State transition: %s -> %s", self._state, new_state)
|
||||
self._state = new_state
|
||||
|
||||
if new_state == SatelliteState.IDLE:
|
||||
self.unduck()
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.state.event_bus.publish("voice_idle")
|
||||
elif new_state == SatelliteState.LISTENING:
|
||||
self.duck()
|
||||
self.state.event_bus.publish("voice_listen")
|
||||
elif new_state == SatelliteState.THINKING:
|
||||
self.state.event_bus.publish("voice_thinking")
|
||||
elif new_state == SatelliteState.RESPONDING:
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self.state.event_bus.publish("voice_responding")
|
||||
elif new_state == SatelliteState.ERROR:
|
||||
self.state.event_bus.publish("voice_error")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Voice assistant events
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||
self._run_end_received = False
|
||||
self._tts_end_received = False
|
||||
self._is_announcement = False
|
||||
self._tts_url = data.get("url")
|
||||
self._continue_conversation = False
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START:
|
||||
self._set_state(SatelliteState.LISTENING)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START:
|
||||
self.state.event_bus.publish("voice_vad_start", data)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
self._is_streaming_audio = False
|
||||
self._set_state(SatelliteState.THINKING)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END:
|
||||
# No-op for now; could be used for LED cues
|
||||
pass
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
if data.get("continue_conversation") == "1":
|
||||
self._continue_conversation = True
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
self._set_state(SatelliteState.RESPONDING)
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
self._tts_url = data.get("url")
|
||||
self._tts_end_received = True
|
||||
self.play_tts()
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
||||
self._run_end_received = True
|
||||
if self._state != SatelliteState.RESPONDING:
|
||||
self._determine_final_state()
|
||||
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
code = data.get("code")
|
||||
message = data.get("message")
|
||||
_LOGGER.debug(
|
||||
"VoiceAssistant error received: code=%s, message=%s", code, message
|
||||
)
|
||||
|
||||
# Treat "no text recognized" as a benign outcome, not a hard error.
|
||||
if code == "stt-no-text-recognized":
|
||||
_LOGGER.debug(
|
||||
"No text recognized from STT; treating as benign and returning to IDLE."
|
||||
)
|
||||
# Ensure we stop streaming audio for this run
|
||||
self._is_streaming_audio = False
|
||||
# Go directly back to IDLE (unduck, idle LEDs, etc.)
|
||||
self._set_state(SatelliteState.IDLE)
|
||||
return
|
||||
|
||||
# All other errors follow the normal error path.
|
||||
self._set_state(SatelliteState.ERROR)
|
||||
# After a brief period, return to IDLE automatically.
|
||||
self.state.loop.call_later(5.0, self._set_state, SatelliteState.IDLE)
|
||||
|
||||
def handle_timer_event(
|
||||
self,
|
||||
event_type: VoiceAssistantTimerEventType,
|
||||
msg: VoiceAssistantTimerEventResponse,
|
||||
) -> None:
|
||||
_LOGGER.debug("Timer event: type=%s", event_type.name)
|
||||
if event_type == VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED:
|
||||
if not self._timer_finished:
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._timer_finished = True
|
||||
self.duck()
|
||||
self._play_timer_finished()
|
||||
|
||||
# Schedule auto-stop if configured
|
||||
duration = getattr(
|
||||
self.state.preferences, "alarm_duration_seconds", 0
|
||||
)
|
||||
if duration > 0:
|
||||
if self._timer_auto_stop_handle is not None:
|
||||
self._timer_auto_stop_handle.cancel()
|
||||
_LOGGER.debug(
|
||||
"Scheduling auto-stop for timer alarm after %s seconds",
|
||||
duration,
|
||||
)
|
||||
self._timer_auto_stop_handle = self.state.loop.call_later(
|
||||
duration, self._auto_stop_timer_alarm
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Main message handler (called by APIServer)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def handle_message(self, msg: message.Message) -> Iterable[message.Message]:
|
||||
"""
|
||||
Handles incoming messages from Home Assistant.
|
||||
Note: This method is synchronous. Long-running operations must be offloaded to Tasks.
|
||||
"""
|
||||
if isinstance(msg, VoiceAssistantEventResponse):
|
||||
data: Dict[str, str] = {}
|
||||
for arg in msg.data:
|
||||
data[arg.name] = arg.value
|
||||
self.handle_voice_event(VoiceAssistantEventType(msg.event_type), data)
|
||||
|
||||
elif isinstance(msg, VoiceAssistantAnnounceRequest):
|
||||
_LOGGER.debug("Announcing: %s", msg.text)
|
||||
urls: List[str] = []
|
||||
if msg.preannounce_media_id:
|
||||
urls.append(msg.preannounce_media_id)
|
||||
urls.append(msg.media_id)
|
||||
|
||||
self._is_announcement = True
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self._continue_conversation = msg.start_conversation
|
||||
self.duck()
|
||||
self._set_state(SatelliteState.RESPONDING)
|
||||
yield from self.media_player_entity.play(
|
||||
urls, announcement=True, done_callback=self._tts_finished
|
||||
)
|
||||
|
||||
elif isinstance(msg, VoiceAssistantTimerEventResponse):
|
||||
self.handle_timer_event(VoiceAssistantTimerEventType(msg.event_type), msg)
|
||||
|
||||
elif isinstance(msg, DeviceInfoRequest):
|
||||
yield DeviceInfoResponse(
|
||||
uses_password=False,
|
||||
name=self.state.name,
|
||||
mac_address=self.state.mac_address,
|
||||
voice_assistant_feature_flags=(
|
||||
VoiceAssistantFeature.VOICE_ASSISTANT
|
||||
| VoiceAssistantFeature.API_AUDIO
|
||||
| VoiceAssistantFeature.ANNOUNCE
|
||||
| VoiceAssistantFeature.START_CONVERSATION
|
||||
| VoiceAssistantFeature.TIMERS
|
||||
),
|
||||
)
|
||||
|
||||
elif isinstance(
|
||||
msg,
|
||||
(
|
||||
ListEntitiesRequest,
|
||||
SubscribeHomeAssistantStatesRequest,
|
||||
MediaPlayerCommandRequest,
|
||||
),
|
||||
):
|
||||
for entity in self.state.entities:
|
||||
yield from entity.handle_message(msg)
|
||||
|
||||
if isinstance(msg, ListEntitiesRequest):
|
||||
yield ListEntitiesDoneResponse()
|
||||
|
||||
elif isinstance(msg, VoiceAssistantConfigurationRequest):
|
||||
# Build list of available wake words (built-in + external)
|
||||
available_wake_words: List[VoiceAssistantWakeWord] = [
|
||||
VoiceAssistantWakeWord(
|
||||
id=ww.id,
|
||||
wake_word=ww.wake_word,
|
||||
trained_languages=ww.trained_languages,
|
||||
)
|
||||
for ww in self.state.available_wake_words.values()
|
||||
]
|
||||
|
||||
# Reset external wake words cache and add new ones
|
||||
self._external_wake_words.clear()
|
||||
for eww in msg.external_wake_words:
|
||||
if eww.model_type != "micro":
|
||||
_LOGGER.warning(
|
||||
"Skipping external wake word %s (type=%s)",
|
||||
eww.id,
|
||||
eww.model_type,
|
||||
)
|
||||
continue
|
||||
|
||||
available_wake_words.append(
|
||||
VoiceAssistantWakeWord(
|
||||
id=eww.id,
|
||||
wake_word=eww.wake_word,
|
||||
trained_languages=eww.trained_languages,
|
||||
)
|
||||
)
|
||||
self._external_wake_words[eww.id] = eww
|
||||
|
||||
_LOGGER.debug(
|
||||
"VoiceAssistantConfigurationRequest: external_wake_words=%s",
|
||||
[eww.id for eww in msg.external_wake_words],
|
||||
)
|
||||
|
||||
# IMPORTANT: Use self.state.active_wake_words directly here, instead of
|
||||
# filtering through self.state.wake_words (which may not yet contain
|
||||
# newly requested models while they're still loading).
|
||||
yield VoiceAssistantConfigurationResponse(
|
||||
available_wake_words=available_wake_words,
|
||||
active_wake_words=sorted(self.state.active_wake_words),
|
||||
max_active_wake_words=2,
|
||||
)
|
||||
|
||||
_LOGGER.info("Connected to Home Assistant")
|
||||
self._set_state(SatelliteState.IDLE)
|
||||
self.state.event_bus.publish("ha_connected")
|
||||
|
||||
elif isinstance(msg, VoiceAssistantSetConfiguration):
|
||||
requested_ids = list(msg.active_wake_words)
|
||||
_LOGGER.debug(
|
||||
"VoiceAssistantSetConfiguration received: active_wake_words=%s",
|
||||
requested_ids,
|
||||
)
|
||||
|
||||
# Update the active_wake_words set immediately so that the *next*
|
||||
# VoiceAssistantConfigurationRequest sees the new state, even while
|
||||
# we are still downloading/loading models in the background.
|
||||
self.state.active_wake_words = set(requested_ids)
|
||||
|
||||
# Offload heavy work (downloads + model loading) to a background task
|
||||
self.state.loop.create_task(self._handle_set_configuration_task(msg))
|
||||
# Yield nothing immediately; response to ConfigurationRequest is handled
|
||||
# separately in the VoiceAssistantConfigurationRequest branch.
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SetConfiguration handler (async, runs in background)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _handle_set_configuration_task(
|
||||
self, msg: VoiceAssistantSetConfiguration
|
||||
) -> None:
|
||||
"""Asynchronous handler for SetConfiguration to avoid blocking I/O."""
|
||||
requested_ids = list(msg.active_wake_words)
|
||||
_LOGGER.debug(
|
||||
"Applying SetConfiguration: requested active_wake_words=%s",
|
||||
requested_ids,
|
||||
)
|
||||
|
||||
active_wake_words: Set[str] = set()
|
||||
|
||||
for wake_word_id in requested_ids:
|
||||
if wake_word_id in self.state.wake_words:
|
||||
# Already loaded in this process; just mark it active.
|
||||
active_wake_words.add(wake_word_id)
|
||||
continue
|
||||
|
||||
model_info = self.state.available_wake_words.get(wake_word_id)
|
||||
|
||||
if not model_info:
|
||||
external_wake_word = self._external_wake_words.get(wake_word_id)
|
||||
if not external_wake_word:
|
||||
_LOGGER.warning("Unknown wake word: %s", wake_word_id)
|
||||
continue
|
||||
|
||||
# Await the non-blocking download
|
||||
model_info = await self._download_external_wake_word(
|
||||
external_wake_word
|
||||
)
|
||||
if not model_info:
|
||||
continue
|
||||
|
||||
self.state.available_wake_words[wake_word_id] = model_info
|
||||
|
||||
_LOGGER.debug("Loading wake word: %s", model_info.wake_word_path)
|
||||
self.state.wake_words[wake_word_id] = model_info.load()
|
||||
|
||||
_LOGGER.info("Wake word set: %s", wake_word_id)
|
||||
active_wake_words.add(wake_word_id)
|
||||
# Do NOT break; we want to process all requested wake words.
|
||||
|
||||
# Finalize active wake words with the subset that actually succeeded
|
||||
self.state.active_wake_words = active_wake_words
|
||||
_LOGGER.debug(
|
||||
"Active wake words after SetConfiguration: %s", active_wake_words
|
||||
)
|
||||
|
||||
self.state.preferences.active_wake_words = list(active_wake_words)
|
||||
self.state.save_preferences()
|
||||
self.state.wake_words_changed = True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Audio handling and wake word triggers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def handle_audio(self, audio_chunk: bytes) -> None:
|
||||
if self._is_streaming_audio:
|
||||
self.send_messages([VoiceAssistantAudio(data=audio_chunk)])
|
||||
|
||||
def _clear_timer_auto_stop(self) -> None:
|
||||
"""Cancel any pending auto-stop for the timer alarm."""
|
||||
if self._timer_auto_stop_handle is not None:
|
||||
try:
|
||||
self._timer_auto_stop_handle.cancel()
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to cancel timer auto-stop handle")
|
||||
finally:
|
||||
self._timer_auto_stop_handle = None
|
||||
|
||||
def _stop_timer_alarm(self, reason: str) -> None:
|
||||
"""
|
||||
Stop the repeating timer-finished alarm sound and clean up flags.
|
||||
|
||||
This is used for:
|
||||
- Stop wake word / explicit stop()
|
||||
- Any wake word while timer is ringing (existing behavior)
|
||||
- Auto-stop after alarm_duration_seconds
|
||||
"""
|
||||
if not self._timer_finished:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Stopping timer finished sound (%s)", reason)
|
||||
self._timer_finished = False
|
||||
self._clear_timer_auto_stop()
|
||||
try:
|
||||
self.state.tts_player.stop()
|
||||
except Exception:
|
||||
_LOGGER.exception("Error stopping timer finished TTS player")
|
||||
# Ensure we unduck and remove the stop word from active set
|
||||
self.unduck()
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
|
||||
def _auto_stop_timer_alarm(self) -> None:
|
||||
"""Auto-stop callback fired after alarm_duration_seconds."""
|
||||
if not self._timer_finished:
|
||||
return
|
||||
duration = getattr(self.state.preferences, "alarm_duration_seconds", 0)
|
||||
_LOGGER.debug(
|
||||
"Auto-stopping timer finished alarm after %s seconds", duration
|
||||
)
|
||||
self._stop_timer_alarm("auto_timeout")
|
||||
|
||||
def _start_conversation(self, wake_word_phrase: str) -> None:
|
||||
"""Shared helper to start a new conversation run."""
|
||||
_LOGGER.debug("Starting conversation: %s", wake_word_phrase)
|
||||
self.send_messages(
|
||||
[VoiceAssistantRequest(start=True, wake_word_phrase=wake_word_phrase)]
|
||||
)
|
||||
self._set_state(SatelliteState.LISTENING)
|
||||
self._is_streaming_audio = True
|
||||
self.state.tts_player.play(self.state.wakeup_sound)
|
||||
|
||||
def wakeup(self, wake_word: Union[MicroWakeWord, OpenWakeWord]) -> None:
|
||||
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
|
||||
# Existing behavior: ignore wakeup in other states.
|
||||
return
|
||||
|
||||
# If a timer alarm is currently ringing, stop it instead of starting
|
||||
# a new conversation run.
|
||||
if self._timer_finished:
|
||||
self._stop_timer_alarm("wakeup")
|
||||
return
|
||||
|
||||
wake_word_phrase = getattr(wake_word, "wake_word", "wake word")
|
||||
_LOGGER.debug("Detected wake word: %s", wake_word_phrase)
|
||||
self._start_conversation(wake_word_phrase)
|
||||
|
||||
def manual_wakeup(self, phrase: str = "button") -> None:
|
||||
"""
|
||||
Manual wakeup entrypoint (e.g. hardware button) that behaves like a
|
||||
wake word, but without requiring a wake-word model instance.
|
||||
"""
|
||||
if self._state not in (SatelliteState.IDLE, SatelliteState.STARTING):
|
||||
return
|
||||
|
||||
if self._timer_finished:
|
||||
self._stop_timer_alarm("button")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Manual wakeup triggered: %s", phrase)
|
||||
self._start_conversation(phrase)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Called when the Stop wake word is detected (or equivalent).
|
||||
|
||||
For timer alarms:
|
||||
- Stop the repeating alarm.
|
||||
For TTS:
|
||||
- Stop playback and treat as user-aborted response.
|
||||
"""
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
|
||||
# If the timer alarm is ringing, stop that instead of a TTS run.
|
||||
if self._timer_finished:
|
||||
self._stop_timer_alarm("stop_wake_word")
|
||||
return
|
||||
|
||||
# Otherwise this is stopping a TTS response.
|
||||
self.state.tts_player.stop()
|
||||
_LOGGER.debug("TTS response stopped manually")
|
||||
self._tts_finished()
|
||||
|
||||
def play_tts(self) -> None:
|
||||
if not self._tts_url:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Playing TTS response: %s", self._tts_url)
|
||||
self.state.active_wake_words.add(self.state.stop_word.id)
|
||||
self.state.tts_player.play(self._tts_url, done_callback=self._tts_finished)
|
||||
self._tts_url = None
|
||||
|
||||
def duck(self) -> None:
|
||||
_LOGGER.debug("Ducking music")
|
||||
self.state.music_player.duck()
|
||||
|
||||
def unduck(self) -> None:
|
||||
_LOGGER.debug("Unducking music")
|
||||
self.state.music_player.unduck()
|
||||
|
||||
def _determine_final_state(self) -> None:
|
||||
if self._continue_conversation:
|
||||
self.send_messages([VoiceAssistantRequest(start=True)])
|
||||
self._is_streaming_audio = True
|
||||
_LOGGER.debug("Continuing conversation")
|
||||
self._set_state(SatelliteState.LISTENING)
|
||||
else:
|
||||
self._set_state(SatelliteState.IDLE)
|
||||
|
||||
_LOGGER.debug("Final state determined")
|
||||
|
||||
def _tts_finished(self) -> None:
|
||||
self.state.active_wake_words.discard(self.state.stop_word.id)
|
||||
self.send_messages([VoiceAssistantAnnounceFinished()])
|
||||
_LOGGER.debug("TTS audio playback finished")
|
||||
|
||||
# If this was just an announcement, we are done,
|
||||
# or if we received the official Run End.
|
||||
if self._is_announcement or self._run_end_received:
|
||||
self._determine_final_state()
|
||||
self._is_announcement = False
|
||||
else:
|
||||
if self._state == SatelliteState.RESPONDING:
|
||||
self._set_state(SatelliteState.THINKING)
|
||||
|
||||
def _play_timer_finished(self) -> None:
|
||||
"""
|
||||
Play the timer-finished sound in a loop until either:
|
||||
- _timer_finished is cleared (Stop/wakeup/auto-timeout), or
|
||||
- alarm_duration_seconds == 0 and user explicitly stops it.
|
||||
"""
|
||||
if not self._timer_finished:
|
||||
# Alarm has been cleared; restore audio state.
|
||||
self.unduck()
|
||||
return
|
||||
self.state.tts_player.play(
|
||||
self.state.timer_finished_sound,
|
||||
done_callback=lambda: call_all(
|
||||
lambda: time.sleep(1.0), self._play_timer_finished
|
||||
),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# External wake word download helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _download_external_wake_word(
|
||||
self, external_wake_word: VoiceAssistantExternalWakeWord
|
||||
) -> Optional[AvailableWakeWord]:
|
||||
"""Wrapper to run the blocking download in a thread executor."""
|
||||
return await self.state.loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
self._download_external_wake_word_sync, external_wake_word
|
||||
),
|
||||
)
|
||||
|
||||
def _download_external_wake_word_sync(
|
||||
self, external_wake_word: VoiceAssistantExternalWakeWord
|
||||
) -> Optional[AvailableWakeWord]:
|
||||
"""Blocking download logic, intended to run in an executor."""
|
||||
eww_dir = self.state.download_dir / "external_wake_words"
|
||||
eww_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
config_path = eww_dir / f"{external_wake_word.id}.json"
|
||||
should_download_config = not config_path.exists()
|
||||
|
||||
model_path = eww_dir / f"{external_wake_word.id}.tflite"
|
||||
should_download_model = True
|
||||
if model_path.exists():
|
||||
model_size = model_path.stat().st_size
|
||||
if model_size == external_wake_word.model_size:
|
||||
with open(model_path, "rb") as model_file:
|
||||
model_hash = hashlib.sha256(model_file.read()).hexdigest()
|
||||
|
||||
if model_hash == external_wake_word.model_hash:
|
||||
should_download_model = False
|
||||
_LOGGER.debug(
|
||||
"Model size and hash match for %s. Skipping download.",
|
||||
external_wake_word.id,
|
||||
)
|
||||
|
||||
if should_download_config or should_download_model:
|
||||
_LOGGER.debug(
|
||||
"Downloading %s to %s", external_wake_word.url, config_path
|
||||
)
|
||||
try:
|
||||
with urlopen(external_wake_word.url, timeout=10) as request:
|
||||
if request.status != 200:
|
||||
_LOGGER.warning(
|
||||
"Failed to download: %s, status=%s",
|
||||
external_wake_word.url,
|
||||
request.status,
|
||||
)
|
||||
return None
|
||||
|
||||
with open(config_path, "wb") as model_file:
|
||||
shutil.copyfileobj(request, model_file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.error("Exception downloading config: %s", exc)
|
||||
return None
|
||||
|
||||
if should_download_model:
|
||||
parsed_url = urlparse(external_wake_word.url)
|
||||
parsed_url = parsed_url._replace(
|
||||
path=posixpath.join(
|
||||
posixpath.dirname(parsed_url.path), model_path.name
|
||||
)
|
||||
)
|
||||
model_url = urlunparse(parsed_url)
|
||||
|
||||
_LOGGER.debug("Downloading %s to %s", model_url, model_path)
|
||||
try:
|
||||
with urlopen(model_url, timeout=10) as request:
|
||||
if request.status != 200:
|
||||
_LOGGER.warning(
|
||||
"Failed to download: %s, status=%s",
|
||||
model_url,
|
||||
request.status,
|
||||
)
|
||||
return None
|
||||
|
||||
with open(model_path, "wb") as model_file:
|
||||
shutil.copyfileobj(request, model_file)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.error("Exception downloading model: %s", exc)
|
||||
return None
|
||||
|
||||
return AvailableWakeWord(
|
||||
id=external_wake_word.id,
|
||||
type=WakeWordType.MICRO_WAKE_WORD,
|
||||
wake_word=external_wake_word.wake_word,
|
||||
trained_languages=external_wake_word.trained_languages,
|
||||
wake_word_path=config_path,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def connection_lost(self, exc):
|
||||
super().connection_lost(exc)
|
||||
self._set_state(SatelliteState.ERROR)
|
||||
_LOGGER.info("Disconnected from Home Assistant")
|
||||
@@ -1,22 +1,46 @@
|
||||
"""Utility methods."""
|
||||
|
||||
import platform
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Optional
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_CACHED_MAC: Optional[str] = None
|
||||
|
||||
def get_mac() -> str:
|
||||
mac = uuid.getnode()
|
||||
mac_str = ":".join(f"{(mac >> i) & 0xff:02x}" for i in range(40, -1, -8))
|
||||
return mac_str
|
||||
|
||||
def get_mac_address() -> str:
|
||||
"""
|
||||
Get the MAC address as a hex string (lowercase, no colons).
|
||||
Example: "b827eb123456"
|
||||
|
||||
This is a thin wrapper around uuid.getnode(), cached so we only
|
||||
compute/log it once per process.
|
||||
"""
|
||||
global _CACHED_MAC
|
||||
if _CACHED_MAC:
|
||||
return _CACHED_MAC
|
||||
|
||||
node = uuid.getnode()
|
||||
mac_hex = f"{node:012x}"
|
||||
|
||||
# If the multicast bit is set, this is probably not a real hardware MAC.
|
||||
if (node >> 40) & 1:
|
||||
_LOGGER.warning(
|
||||
"uuid.getnode() returned a MAC with the multicast bit set; "
|
||||
"discovery identity may change on reboot."
|
||||
)
|
||||
|
||||
_LOGGER.debug("Using MAC address from uuid.getnode(): %s", mac_hex)
|
||||
_CACHED_MAC = mac_hex
|
||||
return _CACHED_MAC
|
||||
|
||||
|
||||
def format_mac(mac: str) -> str:
|
||||
"""Format a hex MAC string with colons (e.g., aa:bb:cc:dd:ee:ff)."""
|
||||
return ":".join(mac[i : i + 2] for i in range(0, 12, 2))
|
||||
|
||||
|
||||
def call_all(*callables: Optional[Callable[[], None]]) -> None:
|
||||
for item in filter(None, callables):
|
||||
item()
|
||||
|
||||
|
||||
def is_arm() -> bool:
|
||||
machine = platform.machine()
|
||||
return ("arm" in machine) or ("aarch" in machine)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""XVF3800 USB-based button & mute controller.
|
||||
|
||||
This module integrates the ReSpeaker XVF3800's built-in mute/mic-control
|
||||
with the Linux Voice Assistant (LVA):
|
||||
|
||||
- Observes the XVF3800's GPO state to detect hardware mute changes.
|
||||
- Publishes "set_mic_mute" events on the EventBus when the user presses
|
||||
the physical mute button on the XVF3800.
|
||||
- Listens for "mic_muted"/"mic_unmuted" events and mirrors the LVA's
|
||||
mute state back to the XVF3800 via its USB control interface.
|
||||
|
||||
The XVF3800's GPIO mapping (from Seeed docs) is:
|
||||
|
||||
GPI (inputs):
|
||||
X1D09 - Mute button state (high = released, low = pressed)
|
||||
X1D13 - Floating
|
||||
X1D34 - Floating
|
||||
|
||||
GPO (outputs):
|
||||
X0D11 - Floating
|
||||
X0D30 - Mic mute + red mute LED (high = muted, LED on)
|
||||
X0D31 - Amplifier enable (low = enabled)
|
||||
X0D33 - WS2812 LED power enable
|
||||
X0D39 - Floating
|
||||
|
||||
We do NOT try to interpret short/long presses like the generic GPIO
|
||||
ButtonController; the XVF3800 mute button is treated as "mute toggle only".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
import usb.core # type: ignore[import]
|
||||
import usb.util # type: ignore[import]
|
||||
|
||||
from .event_bus import EventBus, EventHandler, subscribe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import ServerState
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level USB client (minimal subset of the XMOS/Seeed control protocol)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class XVF3800USBClient:
|
||||
"""Minimal USB control client for the ReSpeaker XVF3800.
|
||||
|
||||
This wraps the vendor-specific control transfers needed for:
|
||||
- GPO_READ_VALUES (read mic mute LED / amp / LED power pins)
|
||||
- GPO_WRITE_VALUE (set mic mute LED pin)
|
||||
|
||||
It deliberately does NOT touch interfaces/altsettings so that audio
|
||||
streaming over USB is unaffected.
|
||||
"""
|
||||
|
||||
VENDOR_ID = 0x2886
|
||||
PRODUCT_ID = 0x001A
|
||||
TIMEOUT_MS = 1000
|
||||
|
||||
# From Seeed's documentation (GPO control):
|
||||
# - Resid = 20 (GPO_SERVICER_RESID)
|
||||
# - CmdId = 0 -> GPO_READ_VALUES (3/5 bytes depending on doc version)
|
||||
# - CmdId = 1 -> GPO_WRITE_VALUE
|
||||
#
|
||||
# We expect GPO_READ_VALUES to return 5 bytes:
|
||||
# [X0D11, X0D30, X0D31, X0D33, X0D39]
|
||||
#
|
||||
GPO_RESID = 20
|
||||
GPO_READ_CMDID = 0
|
||||
GPO_WRITE_CMDID = 1
|
||||
GPO_NUM_PINS = 5
|
||||
|
||||
# Index of the mic mute/red LED pin in the GPO_READ_VALUES response
|
||||
# (X0D30, second element).
|
||||
GPO_MUTE_INDEX = 1
|
||||
# Index of the WS2812 LED power enable pin in the response (X0D33).
|
||||
GPO_WS2812_POWER_INDEX = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
dev = usb.core.find(idVendor=self.VENDOR_ID, idProduct=self.PRODUCT_ID)
|
||||
if dev is None:
|
||||
raise RuntimeError(
|
||||
"ReSpeaker XVF3800 USB device not found "
|
||||
f"(VID=0x{self.VENDOR_ID:04X}, PID=0x{self.PRODUCT_ID:04X})"
|
||||
)
|
||||
|
||||
self._dev = dev
|
||||
_LOGGER.debug(
|
||||
"Initialized XVF3800USBClient (bus=%s, address=%s)",
|
||||
getattr(dev, "bus", "?"),
|
||||
getattr(dev, "address", "?"),
|
||||
)
|
||||
|
||||
# CRITICAL FIX: Add context manager support
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Dispose of USB resources."""
|
||||
if hasattr(self, '_dev') and self._dev is not None:
|
||||
try:
|
||||
usb.util.dispose_resources(self._dev)
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Error disposing XVF3800 USB resources: %s", e)
|
||||
finally:
|
||||
self._dev = None
|
||||
|
||||
# Internal helpers -----------------------------------------------------
|
||||
|
||||
def _ctrl_read(self, resid: int, cmdid: int, length: int) -> List[int]:
|
||||
"""Perform a vendor-specific control IN transfer and return payload bytes."""
|
||||
# Per XMOS protocol: read cmdid is (0x80 | cmdid)
|
||||
wValue = 0x80 | cmdid
|
||||
wIndex = resid
|
||||
|
||||
data = self._dev.ctrl_transfer(
|
||||
usb.util.CTRL_IN | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
||||
0,
|
||||
wValue,
|
||||
wIndex,
|
||||
length,
|
||||
self.TIMEOUT_MS,
|
||||
)
|
||||
if not data:
|
||||
raise RuntimeError("Empty response from XVF3800 vendor control read")
|
||||
|
||||
status = data[0]
|
||||
# 0 = CONTROL_SUCCESS, 64 = SERVICER_COMMAND_RETRY in XMOS docs,
|
||||
# but for our simple use case we just require success.
|
||||
if status != 0:
|
||||
raise RuntimeError(f"Unexpected XVF3800 control status: {status}")
|
||||
|
||||
# Return the payload bytes as a Python list, excluding status byte.
|
||||
return list(data[1:])
|
||||
|
||||
def _ctrl_write(self, resid: int, cmdid: int, payload: bytes) -> None:
|
||||
"""Perform a vendor-specific control OUT transfer."""
|
||||
wValue = cmdid
|
||||
wIndex = resid
|
||||
|
||||
# Note: ctrl_transfer will raise usb.core.USBError on failure.
|
||||
self._dev.ctrl_transfer(
|
||||
usb.util.CTRL_OUT | usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_RECIPIENT_DEVICE,
|
||||
0,
|
||||
wValue,
|
||||
wIndex,
|
||||
payload,
|
||||
self.TIMEOUT_MS,
|
||||
)
|
||||
|
||||
# Public API -----------------------------------------------------------
|
||||
|
||||
def read_gpo_values(self) -> List[int]:
|
||||
"""Read all GPO pin values.
|
||||
|
||||
Returns a list of integers, one per pin:
|
||||
[X0D11, X0D30, X0D31, X0D33, X0D39]
|
||||
"""
|
||||
length = self.GPO_NUM_PINS + 1 # +1 for status byte
|
||||
values = self._ctrl_read(self.GPO_RESID, self.GPO_READ_CMDID, length)
|
||||
if len(values) < self.GPO_NUM_PINS:
|
||||
_LOGGER.warning(
|
||||
"XVF3800 GPO_READ_VALUES returned %d bytes, expected >= %d",
|
||||
len(values),
|
||||
self.GPO_NUM_PINS,
|
||||
)
|
||||
return values
|
||||
|
||||
def set_gpo_pin(self, pin: int, value: bool) -> bool:
|
||||
"""Set a single GPO pin high/low.
|
||||
|
||||
Args:
|
||||
pin: GPO pin number (e.g., 30 for X0D30, 33 for X0D33)
|
||||
value: True -> high, False -> low
|
||||
|
||||
Returns:
|
||||
True on success, False on error.
|
||||
"""
|
||||
payload = bytes([pin, 1 if value else 0])
|
||||
try:
|
||||
self._ctrl_write(self.GPO_RESID, self.GPO_WRITE_CMDID, payload)
|
||||
return True
|
||||
except usb.core.USBError as err:
|
||||
_LOGGER.error("USBError writing XVF3800 GPO pin %s value: %s", pin, err)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error writing XVF3800 GPO pin %s value", pin)
|
||||
return False
|
||||
|
||||
def get_mute_gpo(self) -> Optional[bool]:
|
||||
"""Return the current mic-mute output state from X0D30.
|
||||
|
||||
Returns:
|
||||
True -> mics muted / red mute LED on
|
||||
False -> mics unmuted / red mute LED off
|
||||
None -> could not read values (USB error)
|
||||
"""
|
||||
try:
|
||||
values = self.read_gpo_values()
|
||||
if len(values) <= self.GPO_MUTE_INDEX:
|
||||
_LOGGER.error(
|
||||
"XVF3800 GPO_READ_VALUES payload too short: %r", values
|
||||
)
|
||||
return None
|
||||
mute_val = values[self.GPO_MUTE_INDEX]
|
||||
return bool(mute_val)
|
||||
except usb.core.USBError as err:
|
||||
_LOGGER.error("USBError reading XVF3800 GPO values: %s", err)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error reading XVF3800 GPO values")
|
||||
return None
|
||||
|
||||
def set_mute_gpo(self, muted: bool) -> bool:
|
||||
"""Set the mic-mute output (X0D30) high/low."""
|
||||
return self.set_gpo_pin(30, muted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level controller: bridge between XVF3800 and LVA mute state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class XVF3800ButtonRuntimeConfig:
|
||||
"""Runtime config for the XVF3800 button/mute controller."""
|
||||
|
||||
# CRITICAL FIX: Change default from 0.01s to 0.05s (100 Hz -> 20 Hz)
|
||||
# 80% reduction in CPU usage for button monitoring
|
||||
poll_interval_seconds: float = 0.05 # 20 Hz polling
|
||||
|
||||
|
||||
class XVF3800ButtonController(EventHandler):
|
||||
"""Monitor & synchronize the XVF3800's mute state with LVA."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop,
|
||||
event_bus: EventBus,
|
||||
state: "ServerState",
|
||||
button_config,
|
||||
) -> None:
|
||||
super().__init__(event_bus)
|
||||
self.loop = loop
|
||||
self.state = state
|
||||
|
||||
# CRITICAL FIX: Default to 0.05s instead of 0.01s
|
||||
poll_interval = 0.05
|
||||
if hasattr(button_config, "poll_interval_seconds"):
|
||||
try:
|
||||
poll_interval = float(button_config.poll_interval_seconds)
|
||||
except Exception:
|
||||
_LOGGER.warning(
|
||||
"Invalid poll_interval_seconds in button config; "
|
||||
"defaulting to %.3fs",
|
||||
poll_interval,
|
||||
)
|
||||
|
||||
self._cfg = XVF3800ButtonRuntimeConfig(
|
||||
poll_interval_seconds=poll_interval,
|
||||
)
|
||||
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._shutdown_flag = threading.Event()
|
||||
|
||||
self._last_hw_muted: Optional[bool] = None
|
||||
|
||||
self._target_mute_state_lock = threading.Lock()
|
||||
self._target_mute_state: Optional[bool] = None
|
||||
|
||||
self._usb_client: Optional[XVF3800USBClient] = None
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name="XVF3800ButtonControllerThread",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
_LOGGER.info(
|
||||
"XVF3800ButtonController started (poll_interval=%.3fs)",
|
||||
self._cfg.poll_interval_seconds,
|
||||
)
|
||||
|
||||
self._subscribe_all_methods()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event handlers from EventBus
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@subscribe
|
||||
def mic_muted(self, data: dict) -> None:
|
||||
self._set_target_mute_state(True)
|
||||
|
||||
@subscribe
|
||||
def mic_unmuted(self, data: dict) -> None:
|
||||
self._set_target_mute_state(False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def stop(self) -> None:
|
||||
self._shutdown_flag.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
if self._usb_client is not None:
|
||||
self._usb_client.close()
|
||||
_LOGGER.info("XVF3800ButtonController stopped")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_target_mute_state(self, muted: bool) -> None:
|
||||
with self._target_mute_state_lock:
|
||||
self._target_mute_state = muted
|
||||
|
||||
def _take_target_mute_state(self) -> Optional[bool]:
|
||||
with self._target_mute_state_lock:
|
||||
value = self._target_mute_state
|
||||
self._target_mute_state = None
|
||||
return value
|
||||
|
||||
def _ensure_usb_client(self) -> Optional[XVF3800USBClient]:
|
||||
if self._usb_client is not None:
|
||||
return self._usb_client
|
||||
|
||||
try:
|
||||
self._usb_client = XVF3800USBClient()
|
||||
_LOGGER.info("Connected to ReSpeaker XVF3800 for mute control")
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to initialize XVF3800 USB client; "
|
||||
"mute button integration will be disabled"
|
||||
)
|
||||
self._usb_client = None
|
||||
return self._usb_client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
_LOGGER.debug("XVF3800ButtonController polling thread started")
|
||||
|
||||
while not self._shutdown_flag.is_set() and not getattr(
|
||||
self.state, "shutdown", False
|
||||
):
|
||||
client = self._ensure_usb_client()
|
||||
if client is None:
|
||||
time.sleep(2.0)
|
||||
continue
|
||||
|
||||
# 1) Apply any pending target state from LVA -> hardware
|
||||
target = self._take_target_mute_state()
|
||||
if target is not None:
|
||||
if self._last_hw_muted is None or target != self._last_hw_muted:
|
||||
success = client.set_mute_gpo(target)
|
||||
if success:
|
||||
self._last_hw_muted = target
|
||||
_LOGGER.debug(
|
||||
"Set XVF3800 hardware mute state -> %s", target
|
||||
)
|
||||
|
||||
# 2) Read current hardware GPO values (mute + WS2812 power)
|
||||
try:
|
||||
values = client.read_gpo_values()
|
||||
hw_muted = None
|
||||
if len(values) > client.GPO_MUTE_INDEX:
|
||||
hw_muted = bool(values[client.GPO_MUTE_INDEX])
|
||||
|
||||
# If the firmware/button toggles WS2812 power off, re-enable it so
|
||||
# the ring can still display LVA state (mute, idle effects, etc.)
|
||||
if len(values) > client.GPO_WS2812_POWER_INDEX:
|
||||
ws2812_power = bool(values[client.GPO_WS2812_POWER_INDEX])
|
||||
if not ws2812_power:
|
||||
if client.set_gpo_pin(33, True):
|
||||
_LOGGER.debug("Re-enabled XVF3800 WS2812 LED power (X0D33)")
|
||||
else:
|
||||
ws2812_power = None
|
||||
|
||||
except usb.core.USBError as err:
|
||||
_LOGGER.error("USBError reading XVF3800 GPO values: %s", err)
|
||||
hw_muted = None
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error reading XVF3800 GPO values")
|
||||
hw_muted = None
|
||||
|
||||
if hw_muted is not None:
|
||||
if self._last_hw_muted is None:
|
||||
self._last_hw_muted = hw_muted
|
||||
_LOGGER.info(
|
||||
"Initial XVF3800 hardware mute state: %s", hw_muted
|
||||
)
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.state.event_bus.publish,
|
||||
"set_mic_mute",
|
||||
{"state": hw_muted, "source": "xvf3800_hw"},
|
||||
)
|
||||
elif hw_muted != self._last_hw_muted:
|
||||
_LOGGER.info(
|
||||
"Detected XVF3800 mute state change from %s to %s; "
|
||||
"publishing set_mic_mute event",
|
||||
self._last_hw_muted,
|
||||
hw_muted,
|
||||
)
|
||||
self._last_hw_muted = hw_muted
|
||||
self.loop.call_soon_threadsafe(
|
||||
self.state.event_bus.publish,
|
||||
"set_mic_mute",
|
||||
{"state": hw_muted, "source": "xvf3800_hw"},
|
||||
)
|
||||
|
||||
time.sleep(self._cfg.poll_interval_seconds)
|
||||
|
||||
_LOGGER.debug("XVF3800ButtonController polling thread exiting")
|
||||
@@ -0,0 +1,444 @@
|
||||
"""XVF3800 USB LED ring backend.
|
||||
|
||||
This backend supports two generations of LED control:
|
||||
|
||||
1) Legacy/global LED control (older firmware):
|
||||
- LED_EFFECT, LED_BRIGHTNESS, LED_SPEED, LED_COLOR
|
||||
|
||||
2) Per-LED ring control (newer firmware):
|
||||
- LED_RING_COLOR: 12x uint32 values, one per LED (0xRRGGBB)
|
||||
|
||||
We intentionally keep the implementation minimal and self-contained so it can be
|
||||
used by LVA without shelling out to xvf_host.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import usb.core # type: ignore[import]
|
||||
import usb.util # type: ignore[import]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONTROL_SUCCESS = 0
|
||||
SERVICER_COMMAND_RETRY = 64
|
||||
|
||||
# name -> (resid, cmdid, count, access, type)
|
||||
PARAMETERS = {
|
||||
# ---------------------------------------------------------------------
|
||||
# APPLICATION_SERVICER_RESID (core info)
|
||||
# ---------------------------------------------------------------------
|
||||
"VERSION": (48, 0, 3, "ro", "uint8"),
|
||||
|
||||
# Firmware / device control
|
||||
"REBOOT": (48, 7, 1, "wo", "uint8"),
|
||||
"SAVE_CONFIGURATION": (48, 9, 1, "wo", "uint8"),
|
||||
|
||||
# Audio manager output routing (category, source) per channel
|
||||
"AUDIO_MGR_OP_L": (35, 15, 2, "rw", "uint8"),
|
||||
"AUDIO_MGR_OP_R": (35, 19, 2, "rw", "uint8"),
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# GPO_SERVICER_RESID (LED controls)
|
||||
# ---------------------------------------------------------------------
|
||||
# Legacy/global controls
|
||||
"LED_EFFECT": (20, 12, 1, "rw", "uint8"),
|
||||
"LED_BRIGHTNESS": (20, 13, 1, "rw", "uint8"),
|
||||
"LED_GAMMIFY": (20, 14, 1, "rw", "uint8"),
|
||||
"LED_SPEED": (20, 15, 1, "rw", "uint8"),
|
||||
"LED_COLOR": (20, 16, 1, "rw", "uint32"),
|
||||
|
||||
# Newer firmware: per-LED ring control (12 WS2812 LEDs)
|
||||
"LED_RING_COLOR": (20, 19, 12, "rw", "uint32"),
|
||||
|
||||
# GPO control for button monitoring and LED power
|
||||
"GPO_READ_VALUES": (20, 0, 5, "ro", "uint8"), # [X0D11, X0D30, X0D31, X0D33, X0D39]
|
||||
"GPO_WRITE_VALUE": (20, 1, 2, "wo", "uint8"), # [pin_index, value]
|
||||
}
|
||||
|
||||
|
||||
class _ReSpeaker:
|
||||
"""Low-level USB control wrapper for XVF3800 parameters."""
|
||||
|
||||
TIMEOUT_MS = 100_000
|
||||
VID = 0x2886
|
||||
PID = 0x001A
|
||||
|
||||
def __init__(self, dev: "usb.core.Device") -> None: # type: ignore[name-defined]
|
||||
self.dev = dev
|
||||
|
||||
# CRITICAL FIX: Add context manager support
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit."""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any underlying libusb resources (best-effort)."""
|
||||
if hasattr(self, 'dev') and self.dev is not None:
|
||||
try:
|
||||
usb.util.dispose_resources(self.dev)
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Error disposing USB resources: %s", e)
|
||||
finally:
|
||||
self.dev = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Encoding / decoding helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _pack_values(self, data_type: str, values: Sequence[int]) -> bytes:
|
||||
if data_type == "uint8":
|
||||
return bytes([int(v) & 0xFF for v in values])
|
||||
if data_type == "uint32":
|
||||
return b"".join(struct.pack("<I", int(v) & 0xFFFFFFFF) for v in values)
|
||||
if data_type == "int32":
|
||||
return b"".join(struct.pack("<i", int(v)) for v in values)
|
||||
raise ValueError(f"Unsupported data type '{data_type}'")
|
||||
|
||||
def _unpack_values(self, data_type: str, raw: bytes, count: int) -> List[int]:
|
||||
if data_type == "uint8":
|
||||
return list(raw[:count])
|
||||
if data_type == "uint32":
|
||||
return list(struct.unpack("<" + "I" * count, raw[: count * 4]))
|
||||
if data_type == "int32":
|
||||
return list(struct.unpack("<" + "i" * count, raw[: count * 4]))
|
||||
raise ValueError(f"Unsupported data type '{data_type}'")
|
||||
|
||||
def _read_length(self, data_type: str, count: int) -> int:
|
||||
# +1 for status byte returned by XMOS vendor control read
|
||||
if data_type == "uint8":
|
||||
return count + 1
|
||||
if data_type in ("uint32", "int32"):
|
||||
return (count * 4) + 1
|
||||
raise ValueError(f"Unsupported data type '{data_type}'")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public parameter IO
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write(self, name: str, data_list: Sequence[int]) -> None:
|
||||
try:
|
||||
resid, cmdid, count, access, data_type = PARAMETERS[name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc
|
||||
|
||||
if access == "ro":
|
||||
raise ValueError(f"{name} is read-only")
|
||||
|
||||
if len(data_list) != count:
|
||||
raise ValueError(f"{name} expects {count} values, got {len(data_list)}")
|
||||
|
||||
payload = self._pack_values(data_type, data_list)
|
||||
|
||||
_LOGGER.debug(
|
||||
"XVF3800 write: name=%s resid=%s cmdid=%s payload_len=%d",
|
||||
name,
|
||||
resid,
|
||||
cmdid,
|
||||
len(payload),
|
||||
)
|
||||
|
||||
self.dev.ctrl_transfer(
|
||||
usb.util.CTRL_OUT
|
||||
| usb.util.CTRL_TYPE_VENDOR
|
||||
| usb.util.CTRL_RECIPIENT_DEVICE,
|
||||
0,
|
||||
cmdid,
|
||||
resid,
|
||||
payload,
|
||||
self.TIMEOUT_MS,
|
||||
)
|
||||
|
||||
def read(self, name: str, max_retries: int = 10) -> List[int]:
|
||||
try:
|
||||
resid, cmdid, count, access, data_type = PARAMETERS[name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unknown XVF3800 parameter '{name}'") from exc
|
||||
|
||||
if access == "wo":
|
||||
raise ValueError(f"{name} is write-only")
|
||||
|
||||
length = self._read_length(data_type, count)
|
||||
wValue = 0x80 | cmdid # per XMOS protocol: read is (0x80 | cmdid)
|
||||
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
resp = self.dev.ctrl_transfer(
|
||||
usb.util.CTRL_IN
|
||||
| usb.util.CTRL_TYPE_VENDOR
|
||||
| usb.util.CTRL_RECIPIENT_DEVICE,
|
||||
0,
|
||||
wValue,
|
||||
resid,
|
||||
length,
|
||||
self.TIMEOUT_MS,
|
||||
)
|
||||
|
||||
if not resp:
|
||||
raise RuntimeError("Empty response from XVF3800 control read")
|
||||
|
||||
status = int(resp[0])
|
||||
if status == CONTROL_SUCCESS:
|
||||
raw = bytes(resp[1:])
|
||||
return self._unpack_values(data_type, raw, count)
|
||||
|
||||
if status == SERVICER_COMMAND_RETRY and attempt < max_retries:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"XVF3800 control read failed (status={status}, name={name})")
|
||||
|
||||
|
||||
def _find_device(vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> Optional[_ReSpeaker]:
|
||||
dev = usb.core.find(idVendor=vid, idProduct=pid)
|
||||
if not dev:
|
||||
return None
|
||||
return _ReSpeaker(dev)
|
||||
|
||||
|
||||
class XVF3800USBDevice:
|
||||
"""Small helper for issuing non-LED XVF3800 control commands via PyUSB.
|
||||
|
||||
This is intentionally minimal and does *not* depend on xvf_host.py.
|
||||
"""
|
||||
|
||||
def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID):
|
||||
self._rsp = _find_device(vid=vid, pid=pid)
|
||||
if self._rsp is None:
|
||||
raise RuntimeError(
|
||||
f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._rsp is not None:
|
||||
try:
|
||||
self._rsp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Device control -----------------------------------------------------
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Reboot the XVF3800 firmware (resets parameters to defaults)."""
|
||||
self._rsp.write("REBOOT", [1])
|
||||
|
||||
def save_configuration(self) -> None:
|
||||
"""Persist current runtime settings to flash."""
|
||||
self._rsp.write("SAVE_CONFIGURATION", [1])
|
||||
|
||||
# --- Audio routing ------------------------------------------------------
|
||||
|
||||
def set_audio_mgr_op_l(self, category: int, source: int) -> None:
|
||||
"""Set the L output channel source selection (category, source)."""
|
||||
self._rsp.write("AUDIO_MGR_OP_L", [int(category) & 0xFF, int(source) & 0xFF])
|
||||
|
||||
def set_audio_mgr_op_r(self, category: int, source: int) -> None:
|
||||
"""Set the R output channel source selection (category, source)."""
|
||||
self._rsp.write("AUDIO_MGR_OP_R", [int(category) & 0xFF, int(source) & 0xFF])
|
||||
|
||||
# --- Wait helpers -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def wait_for_reenumeration(
|
||||
vid: int = _ReSpeaker.VID,
|
||||
pid: int = _ReSpeaker.PID,
|
||||
timeout_s: float = 12.0,
|
||||
settle_s: float = 1.0,
|
||||
) -> None:
|
||||
"""Wait for the XVF3800 to disappear and then reappear on USB."""
|
||||
start = time.time()
|
||||
# Wait for it to disappear (best effort)
|
||||
while time.time() - start < timeout_s:
|
||||
if usb.core.find(idVendor=vid, idProduct=pid) is None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Wait for it to reappear
|
||||
while time.time() - start < timeout_s:
|
||||
if usb.core.find(idVendor=vid, idProduct=pid) is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# Give kernel/userspace audio stack a moment to settle
|
||||
if settle_s > 0:
|
||||
time.sleep(settle_s)
|
||||
|
||||
|
||||
class XVF3800LedBackend:
|
||||
"""High-level LED backend for the XVF3800."""
|
||||
|
||||
ring_led_count: int = 12
|
||||
|
||||
# GPO indices (from XVF3800 documentation)
|
||||
GPO_WS2812_POWER_INDEX = 3 # X0D33 in GPO_READ_VALUES response
|
||||
|
||||
def __init__(self, vid: int = _ReSpeaker.VID, pid: int = _ReSpeaker.PID) -> None:
|
||||
wrapper = _find_device(vid, pid)
|
||||
if wrapper is None:
|
||||
raise RuntimeError(
|
||||
f"XVF3800 USB device not found (vid=0x{vid:04x}, pid=0x{pid:04x})"
|
||||
)
|
||||
|
||||
self._dev = wrapper
|
||||
self.supports_per_led: bool = False
|
||||
|
||||
# CRITICAL FIX: Ensure WS2812 LED power is enabled BEFORE any LED operations
|
||||
# This prevents intermittent LED failures caused by X0D33 being low at startup
|
||||
try:
|
||||
_LOGGER.debug("Ensuring XVF3800 WS2812 LED power (X0D33) is enabled")
|
||||
self._dev.write("GPO_WRITE_VALUE", [33, 1]) # X0D33 = high
|
||||
time.sleep(0.05) # Give firmware time to settle
|
||||
_LOGGER.info("XVF3800 WS2812 LED power enabled")
|
||||
except Exception as e:
|
||||
_LOGGER.warning(
|
||||
"Could not enable WS2812 LED power, LEDs may not work reliably: %s", e
|
||||
)
|
||||
|
||||
# Best-effort feature detection: if we can read LED_RING_COLOR, we assume
|
||||
# per-LED control is supported by the current firmware.
|
||||
try:
|
||||
_ = self._dev.read("LED_RING_COLOR")
|
||||
self.supports_per_led = True
|
||||
except Exception:
|
||||
self.supports_per_led = False
|
||||
|
||||
# Best-effort version read (not fatal)
|
||||
try:
|
||||
ver = self._dev.read("VERSION")
|
||||
_LOGGER.info(
|
||||
"Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, version=%s, per_led=%s)",
|
||||
vid,
|
||||
pid,
|
||||
".".join(str(int(x)) for x in ver[:3]),
|
||||
self.supports_per_led,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.info(
|
||||
"Connected to XVF3800 USB device (vid=0x%04x, pid=0x%04x, per_led=%s)",
|
||||
vid,
|
||||
pid,
|
||||
self.supports_per_led,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Helper: Ensure LED power before critical operations
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def _ensure_led_power(self) -> bool:
|
||||
"""Ensure WS2812 LED power is enabled before operations.
|
||||
|
||||
This provides belt-and-suspenders protection against the LED power
|
||||
being disabled by firmware or button interactions.
|
||||
|
||||
Returns:
|
||||
bool: True if power is confirmed on, False if check failed
|
||||
"""
|
||||
try:
|
||||
# Read GPO values
|
||||
values = self._dev.read("GPO_READ_VALUES")
|
||||
if len(values) > self.GPO_WS2812_POWER_INDEX:
|
||||
ws2812_power = bool(values[self.GPO_WS2812_POWER_INDEX])
|
||||
if not ws2812_power:
|
||||
_LOGGER.warning("WS2812 LED power was off, re-enabling")
|
||||
self._dev.write("GPO_WRITE_VALUE", [33, 1])
|
||||
time.sleep(0.01) # Brief settle time
|
||||
return True
|
||||
return True
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Could not verify WS2812 LED power state: %s", e)
|
||||
return False
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Legacy/global controls
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def set_effect(self, effect_id: int) -> None:
|
||||
"""Set LED effect mode (0=off, 1=breath, 2=rainbow, 3=single color, 4=doa)."""
|
||||
# Ensure power before effect change
|
||||
self._ensure_led_power()
|
||||
self._dev.write("LED_EFFECT", [int(effect_id) & 0xFF])
|
||||
|
||||
def set_brightness(self, brightness_0_255: int) -> None:
|
||||
"""Set LED brightness (0-255)."""
|
||||
value = max(0, min(255, int(brightness_0_255)))
|
||||
self._dev.write("LED_BRIGHTNESS", [value])
|
||||
|
||||
def set_speed(self, speed_id: int) -> None:
|
||||
"""Set LED effect speed (0=slow, 1=medium, 2=fast)."""
|
||||
self._dev.write("LED_SPEED", [int(speed_id) & 0xFF])
|
||||
|
||||
def set_color(self, r: int, g: int, b: int) -> None:
|
||||
"""Set LED color for breath / single color modes (0xRRGGBB)."""
|
||||
r = max(0, min(255, int(r)))
|
||||
g = max(0, min(255, int(g)))
|
||||
b = max(0, min(255, int(b)))
|
||||
color_value = (r << 16) | (g << 8) | b
|
||||
self._dev.write("LED_COLOR", [color_value])
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Per-LED ring control (newer firmware)
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def set_ring_colors(self, color_values: Sequence[int]) -> None:
|
||||
"""Set all 12 ring LEDs with 0xRRGGBB values (length must be 12)."""
|
||||
if not self.supports_per_led:
|
||||
raise RuntimeError("Per-LED ring control is not supported by this firmware")
|
||||
if len(color_values) != self.ring_led_count:
|
||||
raise ValueError(
|
||||
f"LED_RING_COLOR expects {self.ring_led_count} values, got {len(color_values)}"
|
||||
)
|
||||
|
||||
# Ensure power before writing ring colors
|
||||
self._ensure_led_power()
|
||||
self._dev.write("LED_RING_COLOR", [int(v) & 0xFFFFFFFF for v in color_values])
|
||||
|
||||
def set_ring_rgb(self, colors: Sequence[Tuple[int, int, int]]) -> None:
|
||||
"""Set all 12 ring LEDs with (r,g,b) tuples (length must be 12)."""
|
||||
if len(colors) != self.ring_led_count:
|
||||
raise ValueError(
|
||||
f"Ring expects {self.ring_led_count} colors, got {len(colors)}"
|
||||
)
|
||||
vals: List[int] = []
|
||||
for r, g, b in colors:
|
||||
r = max(0, min(255, int(r)))
|
||||
g = max(0, min(255, int(g)))
|
||||
b = max(0, min(255, int(b)))
|
||||
vals.append((r << 16) | (g << 8) | b)
|
||||
self.set_ring_colors(vals)
|
||||
|
||||
def set_ring_solid(self, r: int, g: int, b: int) -> None:
|
||||
"""Convenience: set all ring LEDs to the same RGB color."""
|
||||
self.set_ring_rgb([(r, g, b)] * self.ring_led_count)
|
||||
|
||||
def clear_ring(self) -> None:
|
||||
"""Convenience: turn all ring LEDs off (per-LED mode)."""
|
||||
if not self.supports_per_led:
|
||||
# Legacy fallback: just set effect off
|
||||
self.set_effect(0)
|
||||
self.set_brightness(0)
|
||||
return
|
||||
self.set_ring_colors([0] * self.ring_led_count)
|
||||
|
||||
def get_version(self) -> Optional[Tuple[int, int, int]]:
|
||||
"""Return (major, minor, patch) if readable, else None."""
|
||||
try:
|
||||
ver = self._dev.read("VERSION")
|
||||
if len(ver) >= 3:
|
||||
return (int(ver[0]), int(ver[1]), int(ver[2]))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if self._dev is not None:
|
||||
self._dev.close()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Runs mDNS zeroconf service for Home Assistant discovery."""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
from .util import get_mac_address
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
|
||||
except ImportError:
|
||||
_LOGGER.fatal("pip install zeroconf")
|
||||
raise
|
||||
|
||||
MDNS_TARGET_IP = "224.0.0.251"
|
||||
|
||||
|
||||
class HomeAssistantZeroconf:
|
||||
def __init__(
|
||||
self, port: int, name: Optional[str] = None, host: Optional[str] = None
|
||||
) -> None:
|
||||
self.port = port
|
||||
# Use the stable MAC address
|
||||
self.mac_address = get_mac_address()
|
||||
self.name = name or self.mac_address
|
||||
|
||||
if not host:
|
||||
try:
|
||||
test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
test_sock.setblocking(False)
|
||||
test_sock.connect((MDNS_TARGET_IP, 1))
|
||||
host = test_sock.getsockname()[0]
|
||||
test_sock.close()
|
||||
_LOGGER.debug("Detected IP: %s", host)
|
||||
except Exception:
|
||||
_LOGGER.warning("Could not detect IP address, mDNS might fail.")
|
||||
host = "127.0.0.1"
|
||||
|
||||
assert host
|
||||
self.host = host
|
||||
self._aiozc = AsyncZeroconf()
|
||||
|
||||
async def register_server(self) -> None:
|
||||
service_info = AsyncServiceInfo(
|
||||
"_esphomelib._tcp.local.",
|
||||
f"{self.name}._esphomelib._tcp.local.",
|
||||
addresses=[socket.inet_aton(self.host)],
|
||||
port=self.port,
|
||||
properties={
|
||||
"version": "2025.9.0",
|
||||
"mac": self.mac_address,
|
||||
"board": "host",
|
||||
"platform": "HOST",
|
||||
"network": "ethernet",
|
||||
},
|
||||
server=f"{self.name}.local.",
|
||||
)
|
||||
await self._aiozc.async_register_service(service_info)
|
||||
_LOGGER.debug("Zeroconf discovery enabled: %s", service_info)
|
||||
@@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LVA Tray Client
|
||||
|
||||
- Reads linux_voice_assistant/config.json to discover:
|
||||
- app.name -> used for device_id
|
||||
- mqtt.host / port / username / password
|
||||
- Subscribes to the LVA's MQTT topics to:
|
||||
- Track availability (online/offline)
|
||||
- Track mute state
|
||||
- Track per-state LED color (idle/listening/thinking/responding/error)
|
||||
- Shows a system tray icon whose color matches the current LVA state.
|
||||
- Provides a tray menu to start/stop/restart the LVA systemd --user service
|
||||
and toggle microphone mute.
|
||||
|
||||
Idle state now **also uses the MQTT color**, not a fixed purple.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
_LOGGER = logging.getLogger("lva_tray_client")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def slugify_device_id(name: str) -> str:
|
||||
"""Convert display name to a consistent device_id."""
|
||||
return name.strip().lower().replace(" ", "_")
|
||||
|
||||
|
||||
def load_config(config_path: Optional[Path]) -> dict:
|
||||
"""Load LVA config.json."""
|
||||
if config_path is None:
|
||||
# Default: same layout you’re using now:
|
||||
# <repo_root>/linux_voice_assistant/config.json
|
||||
base = Path(__file__).resolve().parent
|
||||
config_path = base / "linux_voice_assistant" / "config.json"
|
||||
|
||||
_LOGGER.info("Using config file: %s", config_path)
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
app_cfg = cfg.get("app", {})
|
||||
mqtt_cfg = cfg.get("mqtt", {})
|
||||
|
||||
name = app_cfg.get("name", "Linux Voice Assistant")
|
||||
device_id = slugify_device_id(name)
|
||||
|
||||
host = mqtt_cfg.get("host", "127.0.0.1")
|
||||
port = int(mqtt_cfg.get("port", 1883))
|
||||
username = mqtt_cfg.get("username") or None
|
||||
password = mqtt_cfg.get("password") or None
|
||||
|
||||
cfg["_resolved"] = {
|
||||
"name": name,
|
||||
"device_id": device_id,
|
||||
"mqtt_enabled": True,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
_LOGGER.info(
|
||||
"Loaded config: name=%s device_id=%s mqtt_enabled=%s host=%s port=%s",
|
||||
name,
|
||||
device_id,
|
||||
True,
|
||||
host,
|
||||
port,
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tray client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LvaTrayClient(QtWidgets.QSystemTrayIcon):
|
||||
"""System tray integration for the Linux Voice Assistant."""
|
||||
|
||||
STATES = ["idle", "listening", "thinking", "responding", "error"]
|
||||
|
||||
def __init__(self, app: QtWidgets.QApplication, cfg: dict):
|
||||
# QSystemTrayIcon init
|
||||
super().__init__(parent=None)
|
||||
self._app = app
|
||||
|
||||
resolved = cfg["_resolved"]
|
||||
self._device_name = resolved["name"]
|
||||
self._device_id = resolved["device_id"]
|
||||
self._mqtt_host = resolved["host"]
|
||||
self._mqtt_port = resolved["port"]
|
||||
self._mqtt_username = resolved["username"]
|
||||
self._mqtt_password = resolved["password"]
|
||||
|
||||
self._topic_prefix = f"lva/{self._device_id}"
|
||||
|
||||
# Current state
|
||||
self._available: bool = False
|
||||
self._muted: bool = False
|
||||
self._current_state: str = "idle"
|
||||
|
||||
# Default colors per state (fallbacks)
|
||||
self._default_colors: Dict[str, QtGui.QColor] = {
|
||||
"idle": QtGui.QColor(128, 0, 255), # purple
|
||||
"listening": QtGui.QColor(0, 0, 255), # blue
|
||||
"thinking": QtGui.QColor(255, 255, 0), # yellow
|
||||
"responding": QtGui.QColor(0, 255, 0), # green
|
||||
"error": QtGui.QColor(255, 165, 0), # orange
|
||||
}
|
||||
# Last MQTT-derived color per state (idle included!)
|
||||
self._last_color_by_state: Dict[str, QtGui.QColor] = dict(
|
||||
self._default_colors
|
||||
)
|
||||
|
||||
# Build context menu
|
||||
self._build_menu()
|
||||
|
||||
# Initial icon: offline
|
||||
self._set_icon_by_key("offline")
|
||||
|
||||
# MQTT setup
|
||||
self._client = mqtt.Client()
|
||||
if self._mqtt_username:
|
||||
self._client.username_pw_set(self._mqtt_username, self._mqtt_password)
|
||||
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_message = self._on_message
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
|
||||
_LOGGER.debug(
|
||||
"MQTT connecting to %s:%s", self._mqtt_host, self._mqtt_port
|
||||
)
|
||||
try:
|
||||
self._client.connect(self._mqtt_host, self._mqtt_port, 60)
|
||||
self._client.loop_start()
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Failed to connect to MQTT broker")
|
||||
|
||||
# Show tray icon
|
||||
self.setVisible(True)
|
||||
_LOGGER.info(
|
||||
"LVA Tray Client started for device_id=%s", self._device_id
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Menu / actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_menu(self):
|
||||
menu = QtWidgets.QMenu()
|
||||
|
||||
# Status label
|
||||
self._status_action = QtWidgets.QAction("LVA Tray Client", self)
|
||||
self._status_action.setEnabled(False)
|
||||
menu.addAction(self._status_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# Service control
|
||||
start_action = QtWidgets.QAction("Start LVA", self)
|
||||
start_action.triggered.connect(self._start_lva)
|
||||
menu.addAction(start_action)
|
||||
|
||||
stop_action = QtWidgets.QAction("Stop LVA", self)
|
||||
stop_action.triggered.connect(self._stop_lva)
|
||||
menu.addAction(stop_action)
|
||||
|
||||
restart_action = QtWidgets.QAction("Restart LVA", self)
|
||||
restart_action.triggered.connect(self._restart_lva)
|
||||
menu.addAction(restart_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Mute toggle
|
||||
self._mute_action = QtWidgets.QAction("Mute Microphone", self)
|
||||
self._mute_action.setCheckable(True)
|
||||
self._mute_action.triggered.connect(self._toggle_mute)
|
||||
menu.addAction(self._mute_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# Quit
|
||||
quit_action = QtWidgets.QAction("Quit Tray Client", self)
|
||||
quit_action.triggered.connect(self._quit)
|
||||
menu.addAction(quit_action)
|
||||
|
||||
self.setContextMenu(menu)
|
||||
|
||||
def _start_lva(self):
|
||||
_LOGGER.info("Running: systemctl --user start linux-voice-assistant.service")
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "start", "linux-voice-assistant.service"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def _stop_lva(self):
|
||||
_LOGGER.info("Running: systemctl --user stop linux-voice-assistant.service")
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "stop", "linux-voice-assistant.service"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def _restart_lva(self):
|
||||
_LOGGER.info("Running: systemctl --user restart linux-voice-assistant.service")
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "restart", "linux-voice-assistant.service"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def _toggle_mute(self, checked: bool):
|
||||
self._mute_action.setChecked(checked)
|
||||
self._muted = checked
|
||||
# Publish to LVA
|
||||
topic = f"{self._topic_prefix}/mute/set"
|
||||
payload = "ON" if checked else "OFF"
|
||||
_LOGGER.debug("Publishing mute command: %s -> %s", topic, payload)
|
||||
try:
|
||||
self._client.publish(topic, payload, retain=False)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Failed to publish mute command")
|
||||
# Update local icon
|
||||
self._update_tray_icon()
|
||||
|
||||
def _quit(self):
|
||||
try:
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._app.quit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MQTT callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_connect(self, client, userdata, flags, rc): # noqa: ARG002
|
||||
if rc == 0:
|
||||
_LOGGER.info("Connected to MQTT broker (tray)")
|
||||
# Subscribe to availability + mute + all LVA topics (for LED states, num_leds, etc.)
|
||||
client.subscribe(f"{self._topic_prefix}/availability")
|
||||
client.subscribe(f"{self._topic_prefix}/mute/state")
|
||||
client.subscribe(f"{self._topic_prefix}/#")
|
||||
else:
|
||||
_LOGGER.error("Failed to connect to MQTT, return code %d", rc)
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc): # noqa: ARG002
|
||||
_LOGGER.warning("MQTT disconnected (rc=%s)", rc)
|
||||
self._available = False
|
||||
self._update_tray_icon()
|
||||
|
||||
def _on_message(self, client, userdata, msg): # noqa: ARG002
|
||||
try:
|
||||
topic = msg.topic
|
||||
payload = msg.payload.decode()
|
||||
_LOGGER.debug(
|
||||
"MQTT message: topic=%s payload=%s", topic, payload
|
||||
)
|
||||
|
||||
# availability
|
||||
if topic == f"{self._topic_prefix}/availability":
|
||||
self._handle_availability(payload)
|
||||
return
|
||||
|
||||
# mute state
|
||||
if topic == f"{self._topic_prefix}/mute/state":
|
||||
self._handle_mute_state(payload)
|
||||
return
|
||||
|
||||
# effect state (we don't currently use it for color, but log it)
|
||||
if topic.endswith("_effect/state"):
|
||||
return
|
||||
|
||||
# light state
|
||||
if topic.endswith("_light/state"):
|
||||
self._handle_light_state(topic, payload)
|
||||
return
|
||||
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Error in _on_message")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MQTT handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_availability(self, payload: str):
|
||||
online = payload.strip().lower() == "online"
|
||||
_LOGGER.debug("Availability update: %s", payload)
|
||||
self._available = online
|
||||
self._update_tray_icon()
|
||||
|
||||
def _handle_mute_state(self, payload: str):
|
||||
new_muted = payload.strip().upper() == "ON"
|
||||
_LOGGER.debug("Mute update: %s", new_muted)
|
||||
self._muted = new_muted
|
||||
# Reflect in menu
|
||||
self._mute_action.setChecked(self._muted)
|
||||
self._update_tray_icon()
|
||||
|
||||
def _handle_light_state(self, topic: str, payload: str):
|
||||
"""
|
||||
Handle JSON from .../<state>_light/state
|
||||
Example payload:
|
||||
{"state": "ON", "brightness": 127,
|
||||
"color": {"r": 0, "g": 0, "b": 255}}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.warning("Invalid JSON on light state: %s", payload)
|
||||
return
|
||||
|
||||
# Extract state name from topic
|
||||
# lva/<device>/<state>_light/state
|
||||
parts = topic.split("/")
|
||||
if len(parts) < 4:
|
||||
return
|
||||
state_part = parts[-2] # "<state>_light"
|
||||
if not state_part.endswith("_light"):
|
||||
return
|
||||
state_name = state_part[: -len("_light")]
|
||||
if state_name not in self.STATES:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Decoded light state: state_name=%s data=%s", state_name, data
|
||||
)
|
||||
|
||||
state_flag = data.get("state", "OFF").upper()
|
||||
color_dict = data.get("color", {}) or {}
|
||||
brightness = int(data.get("brightness", 255))
|
||||
brightness = max(0, min(brightness, 255))
|
||||
|
||||
r = int(color_dict.get("r", 0))
|
||||
g = int(color_dict.get("g", 0))
|
||||
b = int(color_dict.get("b", 0))
|
||||
|
||||
# Apply brightness scaling to the MQTT color
|
||||
scale = brightness / 255.0 if brightness > 0 else 0.0
|
||||
r_scaled = max(0, min(255, int(r * scale)))
|
||||
g_scaled = max(0, min(255, int(g * scale)))
|
||||
b_scaled = max(0, min(255, int(b * scale)))
|
||||
|
||||
qcolor = QtGui.QColor(r_scaled, g_scaled, b_scaled)
|
||||
self._last_color_by_state[state_name] = qcolor
|
||||
|
||||
# State transitions:
|
||||
# - For non-idle: treat "ON" as "this is the active state"
|
||||
# - For idle: **always** treat updates as "idle is the current baseline"
|
||||
if state_name == "idle":
|
||||
_LOGGER.debug(
|
||||
"Tray state_update (idle): data=%s (using MQTT color for idle)",
|
||||
data,
|
||||
)
|
||||
self._current_state = "idle"
|
||||
else:
|
||||
if state_flag == "ON":
|
||||
_LOGGER.debug(
|
||||
"Tray state_update: state_name=%s data=%s",
|
||||
state_name,
|
||||
data,
|
||||
)
|
||||
if self._current_state != state_name:
|
||||
_LOGGER.debug(
|
||||
"Tray state changing: %s -> %s",
|
||||
self._current_state,
|
||||
state_name,
|
||||
)
|
||||
self._current_state = state_name
|
||||
else:
|
||||
# Ignore OFF for non-idle states
|
||||
_LOGGER.debug(
|
||||
"Ignoring OFF for non-idle state %s", state_name
|
||||
)
|
||||
|
||||
self._update_tray_icon()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Icon rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _set_icon_by_key(self, key: str):
|
||||
"""
|
||||
Convenience wrapper to set icon for:
|
||||
- 'offline'
|
||||
- any state name in STATES
|
||||
"""
|
||||
if key == "offline":
|
||||
color = QtGui.QColor(128, 128, 128) # grey
|
||||
tooltip_state = "offline"
|
||||
else:
|
||||
color = self._last_color_by_state.get(
|
||||
key, self._default_colors.get(key, QtGui.QColor(128, 128, 128))
|
||||
)
|
||||
tooltip_state = key
|
||||
|
||||
# If muted, tint red (but keep some info from base color)
|
||||
if self._muted and key != "offline":
|
||||
base = color
|
||||
color = QtGui.QColor(
|
||||
min(255, base.red() + 120),
|
||||
max(0, int(base.green() * 0.4)),
|
||||
max(0, int(base.blue() * 0.4)),
|
||||
)
|
||||
tooltip_state += " (muted)"
|
||||
|
||||
icon = self._make_circle_icon(color)
|
||||
self.setIcon(icon)
|
||||
|
||||
# Update tooltip
|
||||
tip = f"{self._device_name} – {tooltip_state}"
|
||||
self.setToolTip(tip)
|
||||
if self.contextMenu():
|
||||
self._status_action.setText(tip)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Setting tray icon: key=%s available=%s muted=%s", key, self._available, self._muted
|
||||
)
|
||||
|
||||
def _make_circle_icon(self, color: QtGui.QColor) -> QtGui.QIcon:
|
||||
size = 20
|
||||
pixmap = QtGui.QPixmap(size, size)
|
||||
pixmap.fill(QtCore.Qt.transparent)
|
||||
|
||||
painter = QtGui.QPainter(pixmap)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
|
||||
|
||||
pen = QtGui.QPen(QtGui.QColor(0, 0, 0, 160))
|
||||
pen.setWidth(1)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtGui.QBrush(color))
|
||||
|
||||
radius = size // 2 - 2
|
||||
painter.drawEllipse(2, 2, radius * 2, radius * 2)
|
||||
painter.end()
|
||||
|
||||
return QtGui.QIcon(pixmap)
|
||||
|
||||
def _update_tray_icon(self):
|
||||
if not self._available:
|
||||
self._set_icon_by_key("offline")
|
||||
else:
|
||||
self._set_icon_by_key(self._current_state)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="LVA Tray Client")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=str,
|
||||
help="Path to LVA config.json (defaults to linux_voice_assistant/config.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging",
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
config_path = Path(args.config) if args.config else None
|
||||
cfg = load_config(config_path)
|
||||
|
||||
# Make Qt app
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
tray = LvaTrayClient(app, cfg)
|
||||
|
||||
# Start event loop
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
|
||||
[mypy]
|
||||
python_version = 3.9
|
||||
warn_return_any = True
|
||||
warn_unused_configs = True
|
||||
|
||||
[mypy-setuptools.*]
|
||||
[mypy-aioesphomeapi._frame_helper.packets.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-aioesphomeapi.api_pb2.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-pymicro_features.*]
|
||||
[mypy-pymicro_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-sounddevice.*]
|
||||
[mypy-pyopen_wakeword.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-soundcard.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-mpv.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-google.protobuf.*]
|
||||
[mypy-zeroconf.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-paho.mqtt.client.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-RPi.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-spidev.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-board.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-busio.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-gpiozero.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-adafruit_dotstar.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-neopixel.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-neopixel_spi.*]
|
||||
ignore_missing_imports = True
|
||||
|
||||
+26
-10
@@ -22,27 +22,43 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
requires-python = ">=3.9.0"
|
||||
dependencies = [
|
||||
"aioesphomeapi==37.2.1",
|
||||
"sounddevice<1",
|
||||
"aioesphomeapi==43.9.1",
|
||||
"soundcard<1",
|
||||
"numpy>=2,<3",
|
||||
"pymicro-features==1.0.0",
|
||||
"python-mpv=>1,<2",
|
||||
"pymicro-wakeword>=2,<3",
|
||||
"pyopen-wakeword>=1,<2",
|
||||
"python-mpv>=1,<2",
|
||||
"zeroconf<1",
|
||||
"paho-mqtt",
|
||||
"adafruit-circuitpython-dotstar",
|
||||
"adafruit-circuitpython-neopixel",
|
||||
"adafruit-circuitpython-neopixel-spi",
|
||||
"RPi.GPIO",
|
||||
"gpiozero",
|
||||
"spidev",
|
||||
"pyusb",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black==24.8.0",
|
||||
"flake8==7.2.0",
|
||||
"mypy==1.14.0",
|
||||
"pylint==3.2.7",
|
||||
"pytest==8.3.5",
|
||||
"black",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"pylint",
|
||||
"pytest",
|
||||
]
|
||||
|
||||
# Optional GUI/tray dependencies. Only needed on desktop installs that use lva_tray_client.
|
||||
tray = [
|
||||
"PyQt5>=5.15",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Source Code" = "http://github.com/OHF-Voice/linux-voice-assistant"
|
||||
"Source Code" = "https://github.com/imonlinux/linux-voice-assistant"
|
||||
|
||||
[tool.setuptools]
|
||||
platforms = ["any"]
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
aioesphomeapi
|
||||
sounddevice
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
|
||||
# Must be run with sudo.
|
||||
# Requires: curl, dkms, i2c-tools, libasound2-plugins, alsa-utils,
|
||||
# and appropriate Raspberry Pi kernel headers.
|
||||
|
||||
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
|
||||
|
||||
echo "Detecting appropriate kernel header package..."
|
||||
|
||||
header_pkg=""
|
||||
header_candidates=(
|
||||
raspberrypi-kernel-headers
|
||||
linux-headers-rpi-v8
|
||||
linux-headers-rpi-v7l
|
||||
linux-headers-rpi-v7
|
||||
)
|
||||
|
||||
for pkg in "${header_candidates[@]}"; do
|
||||
if apt-cache show "$pkg" >/dev/null 2>&1; then
|
||||
header_pkg="$pkg"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$header_pkg" ]; then
|
||||
echo "Using kernel header package: $header_pkg"
|
||||
else
|
||||
echo "WARNING: No known Raspberry Pi kernel header package found."
|
||||
echo " Tried: ${header_candidates[*]}"
|
||||
echo " Continuing without installing kernel headers; dkms build may fail."
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
apt-get install --no-install-recommends --yes \
|
||||
curl dkms i2c-tools libasound2-plugins alsa-utils \
|
||||
${header_pkg:+$header_pkg}
|
||||
|
||||
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
|
||||
+8
-10
@@ -17,16 +17,14 @@ builder = venv.EnvBuilder(with_pip=True)
|
||||
context = builder.ensure_directories(_VENV_DIR)
|
||||
builder.create(_VENV_DIR)
|
||||
|
||||
# Upgrade dependencies
|
||||
# Upgrade pip and setuptools
|
||||
pip = [context.env_exe, "-m", "pip"]
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
|
||||
|
||||
# Install requirements
|
||||
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
|
||||
subprocess.check_call(pip + ["install", "--upgrade", "pip", "setuptools", "wheel"])
|
||||
|
||||
# Install the project and all its dependencies from pyproject.toml
|
||||
if args.dev:
|
||||
# Install dev requirements
|
||||
subprocess.check_call(
|
||||
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
|
||||
)
|
||||
# Install with dev dependencies
|
||||
subprocess.check_call(pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"])
|
||||
else:
|
||||
# Install with regular dependencies
|
||||
subprocess.check_call(pip + ["install", "-e", str(_PROGRAM_DIR)])
|
||||
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
_PROGRAM_DIR = _DIR.parent
|
||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||
_TEST_DIR = _PROGRAM_DIR / "tests"
|
||||
|
||||
if _VENV_DIR.exists():
|
||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||
python_exe = context.env_exe
|
||||
else:
|
||||
python_exe = "python3"
|
||||
|
||||
subprocess.check_call([python_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve repo root (one level up from this script)
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Activate venv if present
|
||||
if [[ -d "$ROOT_DIR/.venv" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$ROOT_DIR/.venv/bin/activate"
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# Run the tray client
|
||||
exec python lva_tray_client.py "$@"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Load PipeWire echo-cancel module for LVA
|
||||
After=pipewire-pulse.service
|
||||
Wants=pipewire-pulse.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
|
||||
# Use a shell so we can do a little logic:
|
||||
# 1) Try to unload any existing echo-cancel module (ignore errors)
|
||||
# 2) Load the WebRTC echo cancel module with our tuning options
|
||||
ExecStart=/usr/bin/bash -lc '\
|
||||
pactl unload-module module-echo-cancel || true; \
|
||||
pactl load-module module-echo-cancel \
|
||||
aec_method=webrtc \
|
||||
aec_args="analog_gain_control=0 digital_gain_control=1 voice_detection=1" \
|
||||
'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Linux Voice Assistant Tray Client
|
||||
# Make sure we have a graphical session
|
||||
After=graphical-session.target
|
||||
Wants=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||
ExecStart=/home/pi/linux-voice-assistant/script/tray
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Linux Voice Assistant
|
||||
Wants=pipewire.service wireplumber.service pipewire-pulse.service
|
||||
After=pipewire.service wireplumber.service pipewire-pulse.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
ExecStart=/home/pi/linux-voice-assistant/script/run
|
||||
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Linux Voice Assistant
|
||||
Wants=pipewire.service wireplumber.service pipewire-pulse.service
|
||||
After=pipewire.service wireplumber.service pipewire-pulse.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/home/pi/linux-voice-assistant
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
ExecStart=/home/pi/linux-voice-assistant/script/run
|
||||
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,73 @@
|
||||
import argparse
|
||||
import soundcard as sc
|
||||
import numpy as np
|
||||
import wave
|
||||
|
||||
DEVICE = "reSpeaker XVF3800 4-Mic Array Analog Stereo"
|
||||
SECONDS = 5
|
||||
SR = 16000
|
||||
BLOCK = 1024
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Record from soundcard mic to WAV.")
|
||||
parser.add_argument(
|
||||
"--channel",
|
||||
choices=["mix", "left", "right"],
|
||||
default="mix",
|
||||
help="mix=record 1ch (downmix/default). left/right=record 2ch and select.",
|
||||
)
|
||||
parser.add_argument("--seconds", type=int, default=SECONDS)
|
||||
parser.add_argument("--device", type=str, default=DEVICE)
|
||||
parser.add_argument("--out", type=str, default="/tmp/lva_soundcard.wav")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.channel == "mix":
|
||||
in_ch = 1
|
||||
out_ch = 1
|
||||
out_path = args.out
|
||||
else:
|
||||
in_ch = 2
|
||||
out_ch = 1
|
||||
# If user didn't override --out, make filename reflect selection
|
||||
if args.out == "/tmp/lva_soundcard.wav":
|
||||
out_path = f"/tmp/lva_soundcard_{args.channel}.wav"
|
||||
else:
|
||||
out_path = args.out
|
||||
|
||||
mic = sc.get_microphone(args.device, include_loopback=False)
|
||||
print("Using mic:", mic.name)
|
||||
print(f"Mode: {args.channel} (request channels={in_ch} -> write channels={out_ch})")
|
||||
|
||||
frames = []
|
||||
with mic.recorder(samplerate=SR, channels=in_ch, blocksize=BLOCK) as r:
|
||||
for _ in range(int(SR * args.seconds / BLOCK)):
|
||||
buf = r.record(BLOCK)
|
||||
|
||||
if in_ch == 1:
|
||||
# buf can be (BLOCK,) or (BLOCK, 1) depending on backend
|
||||
mono = buf.reshape(-1)
|
||||
sample = mono
|
||||
else:
|
||||
# buf should be (BLOCK, 2)
|
||||
if buf.ndim != 2 or buf.shape[1] < 2:
|
||||
raise RuntimeError(f"Expected stereo buffer, got shape {buf.shape}")
|
||||
idx = 0 if args.channel == "left" else 1
|
||||
sample = buf[:, idx]
|
||||
|
||||
rms = float(np.sqrt(np.mean(np.square(sample)))) if sample.size else 0.0
|
||||
print(f"rms={rms:.6f}")
|
||||
|
||||
pcm16 = (np.clip(sample, -1.0, 1.0) * 32767.0).astype("<i2")
|
||||
frames.append(pcm16.tobytes())
|
||||
|
||||
with wave.open(out_path, "wb") as wf:
|
||||
wf.setnchannels(out_ch)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(SR)
|
||||
wf.writeframes(b"".join(frames))
|
||||
|
||||
print("Wrote:", out_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
"""Tests for microWakeWord."""
|
||||
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from linux_voice_assistant.microwakeword import MicroWakeWord, MicroWakeWordFeatures
|
||||
from linux_voice_assistant.util import is_arm
|
||||
|
||||
_TESTS_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _TESTS_DIR.parent
|
||||
_MICRO_DIR = _REPO_DIR / "wakewords"
|
||||
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
|
||||
|
||||
def test_features() -> None:
|
||||
features = MicroWakeWordFeatures(
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
ww = MicroWakeWord.from_config(
|
||||
config_path=_MICRO_DIR / "okay_nabu.json",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
detected = False
|
||||
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
|
||||
assert wav_file.getframerate() == 16000
|
||||
assert wav_file.getsampwidth() == 2
|
||||
assert wav_file.getnchannels() == 1
|
||||
|
||||
for micro_input in features.process_streaming(
|
||||
wav_file.readframes(wav_file.getnframes())
|
||||
):
|
||||
if ww.process_streaming(micro_input):
|
||||
detected = True
|
||||
|
||||
assert detected
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests for openWakeWord."""
|
||||
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from linux_voice_assistant.openwakeword import OpenWakeWordFeatures, OpenWakeWord
|
||||
from linux_voice_assistant.util import is_arm
|
||||
|
||||
_TESTS_DIR = Path(__file__).parent
|
||||
_REPO_DIR = _TESTS_DIR.parent
|
||||
_OWW_DIR = _REPO_DIR / "wakewords" / "openWakeWord"
|
||||
|
||||
if is_arm():
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_arm64"
|
||||
else:
|
||||
_LIB_DIR = _REPO_DIR / "lib" / "linux_amd64"
|
||||
|
||||
|
||||
libtensorflowlite_c_path = _LIB_DIR / "libtensorflowlite_c.so"
|
||||
|
||||
|
||||
def test_features() -> None:
|
||||
features = OpenWakeWordFeatures(
|
||||
melspectrogram_model=_OWW_DIR / "melspectrogram.tflite",
|
||||
embedding_model=_OWW_DIR / "embedding_model.tflite",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
ww = OpenWakeWord(
|
||||
id="ok_nabu",
|
||||
wake_word="okay nabu",
|
||||
tflite_model=_OWW_DIR / "ok_nabu_v0.1.tflite",
|
||||
libtensorflowlite_c_path=libtensorflowlite_c_path,
|
||||
)
|
||||
|
||||
max_prob = 0.0
|
||||
with wave.open(str(_TESTS_DIR / "ok_nabu.wav"), "rb") as wav_file:
|
||||
assert wav_file.getframerate() == 16000
|
||||
assert wav_file.getsampwidth() == 2
|
||||
assert wav_file.getnchannels() == 1
|
||||
|
||||
for embeddings in features.process_streaming(
|
||||
wav_file.readframes(wav_file.getnframes())
|
||||
):
|
||||
for prob in ww.process_streaming(embeddings):
|
||||
max_prob = max(max_prob, prob)
|
||||
|
||||
assert max_prob > 0.5
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ReSpeaker XVF3800 HID mute/button probe.
|
||||
|
||||
This script helps reverse-engineer how the XVF3800 exposes its mute button over USB HID.
|
||||
It is meant to be run from the linux-voice-assistant virtualenv on a system where the
|
||||
XVF3800 is connected via USB.
|
||||
|
||||
Requirements:
|
||||
pip install hidapi
|
||||
|
||||
Usage examples:
|
||||
|
||||
# Basic usage (auto-detect first 2886:001a HID interface)
|
||||
python tests/xvf3800_hid_mute_probe.py
|
||||
|
||||
# Explicit vendor/product (optional, defaults are correct for XVF3800)
|
||||
python tests/xvf3800_hid_mute_probe.py --vendor-id 0x2886 --product-id 0x001a
|
||||
|
||||
Once running, press and release the XVF3800 mute button a few times. The script will
|
||||
print raw HID reports in hex. Share that output in the Discussion so we can identify
|
||||
which byte/bit corresponds to the mute state.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import binascii
|
||||
import datetime as dt
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
try:
|
||||
import hid # type: ignore[import]
|
||||
except Exception as exc: # pragma: no cover - environment dependent
|
||||
print("ERROR: Failed to import 'hid' (hidapi).", file=sys.stderr)
|
||||
print(" Install it with: pip install hidapi", file=sys.stderr)
|
||||
print(f" Details: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _fmt_vid_pid(vid: int, pid: int) -> str:
|
||||
return f"0x{vid:04x}:0x{pid:04x}"
|
||||
|
||||
|
||||
def list_matching_devices(vendor_id: int, product_id: int) -> List[dict]:
|
||||
devices = []
|
||||
for dev in hid.enumerate():
|
||||
if dev.get("vendor_id") == vendor_id and dev.get("product_id") == product_id:
|
||||
devices.append(dev)
|
||||
return devices
|
||||
|
||||
|
||||
def choose_device(devices: List[dict]) -> Optional[dict]:
|
||||
"""Pick a device from the list; if only one, return it, else pick the first.
|
||||
|
||||
We log all candidates so users can see what's on their system.
|
||||
"""
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
print("Found the following matching HID interfaces for XVF3800:")
|
||||
for idx, dev in enumerate(devices):
|
||||
path = dev.get("path")
|
||||
serial = dev.get("serial_number") or "<none>"
|
||||
mfg = dev.get("manufacturer_string") or "<unknown>"
|
||||
prod = dev.get("product_string") or "<unknown>"
|
||||
iface = dev.get("interface_number", -1)
|
||||
print(
|
||||
f" [{idx}] path={path!r}, serial={serial!r}, iface={iface}, "
|
||||
f"manufacturer={mfg!r}, product={prod!r}"
|
||||
)
|
||||
|
||||
print()
|
||||
print("NOTE: Using index 0 by default. If this is not the HID interface, ")
|
||||
print(" rerun with --path <exact path> from the list above.\n")
|
||||
return devices[0]
|
||||
|
||||
|
||||
def open_device(
|
||||
vendor_id: int,
|
||||
product_id: int,
|
||||
path: Optional[bytes] = None,
|
||||
) -> hid.device:
|
||||
"""Open a HID device for the given VID/PID (and optional path)."""
|
||||
dev = hid.device()
|
||||
if path is not None:
|
||||
print(f"Opening HID device by path: {path!r}")
|
||||
dev.open_path(path)
|
||||
else:
|
||||
print(f"Opening HID device by VID/PID: {_fmt_vid_pid(vendor_id, product_id)}")
|
||||
dev.open(vendor_id, product_id)
|
||||
|
||||
dev.set_nonblocking(True)
|
||||
return dev
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Probe HID reports for the ReSpeaker XVF3800 mute button."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vendor-id",
|
||||
type=lambda x: int(x, 0),
|
||||
default=0x2886,
|
||||
help="USB vendor ID (default: 0x2886 for Seeed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--product-id",
|
||||
type=lambda x: int(x, 0),
|
||||
default=0x001a,
|
||||
help="USB product ID (default: 0x001a for XVF3800)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
type=str,
|
||||
default="",
|
||||
help="Optional exact HID path to open (overrides VID/PID selection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-ms",
|
||||
type=int,
|
||||
default=500,
|
||||
help="Read timeout in milliseconds (default: 500)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=== XVF3800 HID Mute Probe ===")
|
||||
print(f"Vendor/Product: {_fmt_vid_pid(args.vendor_id, args.product_id)}")
|
||||
|
||||
if args.path:
|
||||
chosen_path: Optional[bytes] = args.path.encode("utf-8")
|
||||
else:
|
||||
devices = list_matching_devices(args.vendor_id, args.product_id)
|
||||
if not devices:
|
||||
print(
|
||||
"No HID interfaces found for XVF3800 (VID/PID). Make sure the board is plugged in.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
chosen = choose_device(devices)
|
||||
if chosen is None:
|
||||
print("No device chosen.", file=sys.stderr)
|
||||
return 1
|
||||
chosen_path = chosen.get("path")
|
||||
print("Using device:")
|
||||
print(f" path={chosen_path!r}")
|
||||
print(f" serial={chosen.get('serial_number')!r}")
|
||||
print(f" interface_number={chosen.get('interface_number')}")
|
||||
print()
|
||||
|
||||
try:
|
||||
dev = open_device(args.vendor_id, args.product_id, chosen_path)
|
||||
except Exception as exc: # pragma: no cover - hardware dependent
|
||||
print(f"ERROR: Failed to open HID device: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Listening for HID reports. Press and release the XVF3800 mute button a few times.")
|
||||
print("Press Ctrl+C to stop.\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = dev.read(64, timeout_ms=args.timeout_ms)
|
||||
if data:
|
||||
now = dt.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
raw = bytes(data)
|
||||
hex_str = binascii.hexlify(raw).decode("ascii")
|
||||
# Trim trailing zeroes for readability
|
||||
hex_str = hex_str.rstrip("0") or hex_str
|
||||
print(f"[{now}] len={len(raw):02d} report={hex_str}")
|
||||
time.sleep(0.01)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping probe.")
|
||||
finally:
|
||||
try:
|
||||
dev.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - script entry
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--device",
|
||||
help="Exact input device name to test (as shown by --list-input-devices)",
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--samplerate",
|
||||
type=float,
|
||||
default=16000.0,
|
||||
help="Sample rate to test (default: 16000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seconds",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help="Duration to record for debug (default: 2s)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=== sounddevice.query_devices() (input subset) ===")
|
||||
devices = sd.query_devices()
|
||||
for idx, dev in enumerate(devices):
|
||||
if dev["max_input_channels"] > 0:
|
||||
flag = "*" if args.device in (dev["name"], str(idx)) else " "
|
||||
print(
|
||||
f"{flag} [{idx}] {dev['name']} "
|
||||
f"(max_input_channels={dev['max_input_channels']}, "
|
||||
f"default_samplerate={dev['default_samplerate']})"
|
||||
)
|
||||
|
||||
print("\n=== Opening stream ===")
|
||||
sd.default.samplerate = args.samplerate
|
||||
sd.default.dtype = "int16"
|
||||
|
||||
# Allow using either index or full name
|
||||
device = args.device
|
||||
try:
|
||||
device = int(args.device)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# First just open/close the stream to see what PortAudio says
|
||||
with sd.InputStream(device=device, channels=0) as stream:
|
||||
print(f"Opened stream with device={device}")
|
||||
print(f" samplerate: {stream.samplerate}")
|
||||
print(f" channels: {stream.channels}")
|
||||
print(f" dtype: {stream.dtype}")
|
||||
print(f" blocksize: {stream.blocksize}")
|
||||
|
||||
# Now actually record a short block and inspect the shape
|
||||
print("\n=== Recording test block ===")
|
||||
duration = args.seconds
|
||||
num_frames = int(duration * args.samplerate)
|
||||
print(f"Recording {duration} seconds ({num_frames} frames)...")
|
||||
|
||||
recorded = sd.rec(frames=num_frames, samplerate=args.samplerate,
|
||||
channels=0, dtype="int16", device=device)
|
||||
sd.wait()
|
||||
|
||||
arr = np.array(recorded)
|
||||
print(f"Recorded array shape: {arr.shape}, dtype={arr.dtype}")
|
||||
if arr.ndim == 1:
|
||||
print("-> Mono (1D) array returned")
|
||||
elif arr.ndim == 2:
|
||||
print(f"-> Multichannel: {arr.shape[1]} channels")
|
||||
else:
|
||||
print("-> Unexpected ndim:", arr.ndim)
|
||||
|
||||
print(f"Sample min/max: {arr.min()} / {arr.max()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "micro",
|
||||
"wake_word": "hey_luna",
|
||||
"wake_word": "Hey Luna",
|
||||
"author": "adamlonsdale",
|
||||
"website": "https://github.com/adamlonsdale",
|
||||
"model": "hey_luna.tflite",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Alexa",
|
||||
"model": "alexa_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "HAL",
|
||||
"model": "hal_v2.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Jarvis",
|
||||
"model": "hey_jarvis_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Mycroft",
|
||||
"model": "hey_mycroft_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Hey Rhasspy",
|
||||
"model": "hey_rhasspy_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Marvin",
|
||||
"model": "marvin_v2.tflite"
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "openWakeWord",
|
||||
"wake_word": "Okay Nabu",
|
||||
"model": "ok_nabu_v0.1.tflite"
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user