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