83 Commits
Author SHA1 Message Date
Michael Hansen 962b2f5dc6 Use better --version 2024-02-25 09:51:24 -06:00
Michael Hansen 36353ff03a Flesh out test 2024-02-19 14:20:57 -06:00
Michael Hansen 70652bbea2 Add more tests 2024-02-18 17:27:59 -06:00
Michael Hansen f0dcf39946 Add --mic-channel-index 2024-02-01 16:55:14 -06:00
Michael Hansen 42325ec284 Update changelog 2024-02-01 15:58:25 -06:00
Michael Hansen 1bc3471637 Allow running a different pipeline depending on wake word 2024-01-30 15:39:50 -06:00
Michael Hansen 8850100ca6 Bump version 2024-01-30 14:21:50 -06:00
Michael Hansen 8e793a185d Change to --mic-no-mute-after-awake-wav 2024-01-30 14:21:28 -06:00
Michael Hansen 12cce8e8f5 Merge pull request #91 from Ranger2959/awake-wav-mute-settings
Awake wav mic mute settings
2024-01-30 14:18:43 -06:00
Michael Hansen fbc129711d Change to --tts-played-command 2024-01-30 14:17:48 -06:00
Michael Hansen 7a45d6b2e9 Merge pull request #94 from llluis/played-command
Expose Played command
2024-01-30 14:14:46 -06:00
Michael Hansen 9e6195baa7 Merge pull request #50 from preimmortal/docker
fix: fix docker build scripts and dockerignore for docker version 24.0.7
2024-01-30 14:13:15 -06:00
Luis Andrade 9fda18770b clarify TTS commands 2024-01-28 23:03:01 -05:00
Luis Andrade 503dc952bb add played to readme 2024-01-28 22:59:07 -05:00
Luis Andrade 366547c457 expose played command 2024-01-28 22:53:37 -05:00
Christopher Gozdziewski 74f22e649c Awake wav mic mute settings 2024-01-26 21:42:31 -06:00
preimmortal 865b40722b Merge branch 'master' into docker 2024-01-21 11:47:46 -08:00
Michael Hansen 20bbd3cb4f Merge pull request #72 from llluis/redo-send-played-event
redo the send played event
2024-01-19 23:27:18 -06:00
Michael Hansen 038e9667d2 Merge pull request #73 from andreasbrett/patch-1
Update .dockerignore
2024-01-19 23:24:20 -06:00
Michael Hansen 8b24216e4e Merge pull request #77 from rhasspy/synesthesiam-20241019-log-format
Add --log-format
2024-01-19 17:01:25 -06:00
Michael Hansen 87bfcf4bf7 Add --log-format 2024-01-19 16:58:41 -06:00
Andreas Brett 090b2588b7 Update .dockerignore
VERSION is required within the docker image
2024-01-19 17:59:55 +01:00
Luis Andrade a638600348 redo the send played event 2024-01-19 14:39:03 +00:00
Michael Hansen d1578cb863 Merge pull request #70 from llluis/fix-2mic-tutorial
bump wyoming version in 2mic tutorial
2024-01-19 08:21:08 -06:00
Luis Andrade 086f346119 bump to 1.5.2 2024-01-19 06:44:30 +00:00
Luis Andrade 3b2842d11b undo oups 2024-01-19 04:50:28 +00:00
Luis Andrade 1bceb97177 bump wyoming version in 2mic tutorial 2024-01-19 04:38:57 +00:00
Luis Andrade 71ab73ad73 send played event at the disconnect of client 2024-01-19 04:36:14 +00:00
Michael Hansen 160370be5a Merge pull request #65 from rhasspy/synesthesiam-20240118-wyoming-1.5.2
wyoming 1.5.2
2024-01-18 18:17:21 -06:00
Michael Hansen 594f6f696d Fix requirements 2024-01-18 14:46:57 -06:00
Michael Hansen bd9de77a4c wyoming 1.5.2 2024-01-18 14:46:27 -06:00
Michael Hansen 5088b590a6 wyoming 1.5.1 2024-01-18 11:50:16 -06:00
Michael Hansen 3a91fc468a Only enable ping if server supports it 2024-01-17 12:37:15 -06:00
Michael Hansen 4d52ef387d Start new tutorial 2024-01-16 17:06:30 -06:00
Michael Hansen 35e16ae328 Enable now 2024-01-16 17:06:30 -06:00
Michael Hansen 232b89666a Fix wake word dir 2024-01-16 17:06:30 -06:00
Michael Hansen 40a8516554 Fix service name 2024-01-16 17:06:30 -06:00
Michael Hansen d3277e06f1 Use snd multiplier 2024-01-16 17:06:30 -06:00
Michael Hansen d5740a197f Typos 2024-01-16 17:06:30 -06:00
Michael Hansen f6fcf74c3a Add sound volume multiplier 2024-01-16 17:06:30 -06:00
Michael Hansen a44dc28cdf Fix requirements 2024-01-16 17:06:30 -06:00
Michael Hansen f198e28d80 Better errors for mic autodetect 2024-01-16 17:06:30 -06:00
Michael Hansen 00d2625571 Add system package install 2024-01-16 17:06:30 -06:00
Michael Hansen 73604f9a71 Mute microphone during awake wav 2024-01-16 17:06:30 -06:00
Michael Hansen ebc686d9ad Fixes 2024-01-16 17:06:30 -06:00
Michael Hansen 33e6d80786 Add ping 2024-01-16 17:06:30 -06:00
Michael Hansen db5918e04a Fix tutorial 2024-01-15 19:41:35 -06:00
Michael Hansen 49df03426f Clean up 2024-01-15 11:15:08 -06:00
Michael Hansen 8063f5b773 First working version 2024-01-14 22:19:01 -06:00
Michael Hansen 316427948e Refactor installer 2024-01-14 15:15:44 -06:00
pre 79dee35659 fix: fix docker build scripts, dockerfile, dockerignore 2024-01-13 01:23:26 -08:00
Michael Hansen 03bd01ab1f Add vad 2024-01-11 19:52:22 -06:00
Michael Hansen d29d3d0613 Working on installer 2024-01-11 17:12:25 -06:00
Michael Hansen 693b8e0e19 Check piwheels too 2024-01-10 14:52:48 -06:00
Michael Hansen dc304e21e4 Merge pull request #44 from rhasspy/synesthesiam-20240109-debug-recording
Add debug recording for wake/stt
2024-01-10 13:46:12 -06:00
Michael Hansen f6e397a1b0 Add debug recording for wake/stt 2024-01-10 13:33:19 -06:00
Michael Hansen 350eee6cb2 Don't use asyncio.timeout 2024-01-09 16:25:34 -06:00
Michael Hansen b164bafc9f Add test workflow 2024-01-09 16:21:16 -06:00
Michael Hansen f1c48862a2 Add pytest asyncio dev requirement 2024-01-09 14:53:01 -06:00
Michael Hansen 2d9c7b28aa Document --wake-refractory-seconds 2024-01-09 12:04:17 -06:00
Michael Hansen e7dcbf4383 Add initial test 2024-01-09 12:02:05 -06:00
Michael Hansen 172a85846c Working on tests 2024-01-09 10:59:28 -06:00
Michael Hansen f74071666a More clean up 2024-01-09 10:59:09 -06:00
Michael Hansen da72689bed Clean up and move code to utils 2024-01-09 09:55:15 -06:00
Michael Hansen 970742711d Allow sounds directory in Docker build 2024-01-09 09:38:33 -06:00
Michael Hansen ebfd9fa085 Merge pull request #34 from Graimalkin/fix--add-sounds-in-docker-container
fix: add sounds dir to dockerfile
2024-01-09 09:38:12 -06:00
Michael Hansen ec46c3c6aa Merge pull request #22 from kalfa/patch-1
Update tutorial_2mic.md
2024-01-09 09:36:52 -06:00
Michael Hansen d6a6075376 Fix audio crackling with wyoming update 2024-01-08 16:53:00 -06:00
Michael Hansen 71d7afdc51 Add video tutorial links 2024-01-08 11:46:34 -06:00
Michael Hansen 76af77484e Merge pull request #30 from mweinelt/fix-entrypoint
Implement entrypoint run function
2023-12-28 09:57:31 -06:00
Martin Weinelt 6a1b51e2b4 Implement entrypoint run function
Because otheriwse we run into

AttributeError: module 'wyoming_satellite.__main__' has no attribute 'run'
2023-12-27 18:08:17 +01:00
Michael Hansen 2595e603e8 Add openblas dependency to tutorial 2023-12-26 10:57:58 -06:00
Jessica Fritsch 3ca5b5fd7d fix: add sounds dir to dockerfile 2023-12-23 10:21:02 -07:00
Cosimo Alfarano 59ca3df424 Update tutorial_2mic.md 2023-12-20 23:49:30 +00:00
Michael Hansen 20296973bd Merge pull request #17 from vhsdream/master
change README to reflect correct wake-word name flag
2023-12-17 18:30:55 -06:00
Chris N a881399d23 change README to reflect correct wake-word name flag 2023-12-17 15:09:33 -05:00
Michael Hansen 51426559c8 Merge pull request #15 from adamaze/patch-1
fix typo in tutorial_2mic.md
2023-12-16 18:31:20 -06:00
adamaze ec192b0ee8 fix typo in tutorial_2mic.md 2023-12-16 15:14:38 -06:00
Michael Hansen f3f97bb7f7 Merge pull request #14 from jkms/master
added example LED service for ReSpeaker 4Mic HAT
2023-12-16 13:21:19 -06:00
John 48043d06e9 added example LED service for ReSpeaker 4Mic HAT 2023-12-16 11:01:32 -08:00
Michael Hansen 95f2e58c3a Update README 2023-12-14 19:00:20 -06:00
Michael Hansen 44870ca149 Add tutorial 2023-12-14 16:28:14 -06:00
Michael Hansen 9e2074ccf9 Add respeaker driver install script 2023-12-14 15:12:12 -06:00
50 changed files with 4282 additions and 214 deletions
+3
View File
@@ -5,4 +5,7 @@
!script/setup
!script/run
!wyoming_satellite/*.py
!wyoming_satellite/utils
!wyoming_satellite/VERSION
!docker/run
!sounds/*.wav
+20
View File
@@ -0,0 +1,20 @@
---
name: test
# yamllint disable-line rule:truthy
on:
workflow_dispatch:
pull_request:
jobs:
test_linux:
name: "test on linux"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: setup
run: |
script/setup --dev
- name: test
run: |
script/test
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 1.2.0
- Add `--tts-played-command`
- Add `--mic-seconds-to-mute-after-awake-wav` and `--mic-no-mute-during-awake-wav`
- Send preferred sound format to server
## 1.1.1
- Bump to wyoming 1.5.2 (package fix)
## 1.1.0
- Bump to wyoming 1.5.1
- Send wyoming-satellite version in `info` message
- Ping server if supported (faster awareness of disconnection)
- Support `pause-satellite` message
- Stop streaming/wake word detection as soon as `pause-satellite` is received or server disconnects
- Mute microphone when awake WAV is playing
## 1.0.0
- Initial release
+1
View File
@@ -8,6 +8,7 @@ RUN apt-get update && \
WORKDIR /app
COPY sounds/ ./sounds/
COPY script/setup ./script/
COPY setup.py requirements.txt MANIFEST.in ./
COPY wyoming_satellite/ ./wyoming_satellite/
+1
View File
@@ -1 +1,2 @@
include requirements.txt
include VERSION
+38 -5
View File
@@ -1,9 +1,35 @@
# Wyoming Satellite
Remote satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
Remote voice satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
* Works with [Home Assistant](https://www.home-assistant.io/integrations/wyoming)
* Local wake word detection using [Wyoming services](https://github.com/rhasspy/wyoming#wyoming-projects)
* Audio enhancements using [webrtc](https://github.com/rhasspy/webrtc-noise-gain/)
See [the tutorial](docs/tutorial_2mic.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).
Video tutorials:
* [Wyoming Voice Satellite with ChatGPT](https://www.youtube.com/watch?v=eTKgc0YDCwE)
* [Local ChatGPT Voice Assistant](https://www.youtube.com/watch?v=pAKqKTkx5X4)
---
Requires:
* Python 3.7+ (tested on 3.9+)
* A microphone
## Installation
Install the necessary system dependencies:
``` sh
sudo apt-get install python3-venv python3-pip
```
Then run the install script:
``` sh
script/setup
```
@@ -94,11 +120,15 @@ script/run \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word 'ok_nabu'
--wake-word-name 'ok_nabu'
```
Audio will only be streamed to the server after the wake word has been detected.
Once a wake word has been detected, it can not be detected again for several seconds (called the "refractory period"). You can change this with `--wake-refractory-seconds <SECONDS>`.
Note that `--vad` is unnecessary when connecting to a local instance of openwakeword.
## Sounds
You can play a WAV file when the wake word is detected (locally or remotely), and when speech-to-text has completed:
@@ -125,7 +155,7 @@ script/run \
--mic-noise-suppression 2
```
Automatic gain control is between 0-31, which 31 being the loudest.
Automatic gain control is between 0-31 dbFS, which 31 being the loudest.
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
@@ -143,8 +173,11 @@ Satellites can respond to events from the server by running commands:
* `--stt-start-command` - user started speaking (no stdin)
* `--stt-stop-command` - user stopped speaking (no stdin)
* `--synthesize-command` - text-to-speech text is returned (text on stdin)
* `--tts-start-command` - text-to-speech response started (no stdin)
* `--tts-stop-command` - text-to-speech response stopped (no stdin)
* `--tts-start-command` - text-to-speech response started streaming from server (no stdin)
* `--tts-stop-command` - text-to-speech response stopped streaming from server. Can still being played by snd service (no stdin)
* `--tts-played-command` - text-to-speech audio finished playing (no stdin)
* `--error-command` - an error was sent from the server (text on stdin)
* `--connected-command` - satellite connected to server
* `--disconnected-command` - satellite disconnected from server
For more advanced scenarios, use an event service (`--event-uri`). See `wyoming_satellite/example_event_client.py` for a basic client that just logs events.
+388
View File
@@ -0,0 +1,388 @@
# Tutorial with 2mic HAT
Create a voice 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).
This tutorial should work for almost any Raspberry Pi and USB microphone. Audio enhancements and local wake word detection may require a 64-bit operating system, however.
## Install OS
Follow instructions to [install Raspberry Pi OS](https://www.raspberrypi.com/software/). Under "Choose OS", pick "Raspberry Pi OS (other)" and "Raspberry Pi OS (Legacy, **64-bit**) Lite".
When asking if you'd like to apply customization settings, choose "Edit Settings" and:
* Set a username/password
* Configure the wireless LAN
* Under the Services tab, enable SSH and use password authentication
## Install Software
After flashing and booting the satellite, connect to it over SSH using the username/password you configured during flashing.
**On the satellite**, make sure system dependencies are installed:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
git \
python3-venv
```
Clone the `wyoming-satellite` repository:
```sh
git clone https://github.com/rhasspy/wyoming-satellite.git
```
If you have the ReSpeaker 2Mic or 4Mic HAT, recompile and install the drivers (this will take really long time):
```sh
cd wyoming-satellite/
sudo bash etc/install-respeaker-drivers.sh
```
After install the drivers, you must reboot the satellite:
```sh
sudo reboot
```
Once the satellite has rebooted, reconnect over SSH and continue the installation:
```sh
cd wyoming-satellite/
python3 -m venv .venv
.venv/bin/pip3 install --upgrade pip
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install \
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
-r requirements.txt \
-r requirements_audio_enhancement.txt \
-r requirements_vad.txt
```
If the installation was successful, you should be able to run:
```sh
script/run --help
```
## Determine Audio Devices
Picking the correct microphone/speaker devices is critical for the satellite to work. We'll do a test recording and playback in this section.
List your available microphones with:
```sh
arecord -L
```
If you have the ReSpeaker 2Mic HAT, you should see:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
For other microphones, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
Record a 5 second sample from your chosen microphone:
```sh
arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t wav -d 5 test.wav
```
Say something while `arecord` is running. If you get errors, try a different microphone device by changing `-D <device>`.
List your available speakers with:
```sh
aplay -L
```
If you have the ReSpeaker 2Mic HAT, you should see:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
For other speakers, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
Play back your recorded sample WAV:
```sh
aplay -D plughw:CARD=seeed2micvoicec,DEV=0 test.wav
```
You should hear your recorded sample. If there are problems, try a different speaker device by changing `-D <device>`.
Make note of your microphone and speaker devices for the next step.
## Running the Satellite
In the `wyoming-satellite` directory, run:
```sh
script/run \
--debug \
--name 'my satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
```
Change the `-D <device>` for `arecord` and `aplay` to match the audio devices from the previous section.
You can set `--name <NAME>` to whatever you want, but it should stay the same every time you run the satellite.
In Home Assistant, check the "Devices & services" section in Settings. After some time, you should see your satellite show up as "Discovered" (Wyoming Protocol). Click the "Configure" button and "Submit". Choose the area that your satellite is located, and click "Finish".
Your satellite should say "Streaming audio", and you can use the wake word of your preferred pipeline.
## Create Services
You can run wyoming-satellite as a systemd service by first creating a service file:
``` sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
```text
[Unit]
Description=Wyoming Satellite
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/home/pi/wyoming-satellite/script/run --name 'my satellite' --uri 'tcp://0.0.0.0:10700' --mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' --snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
WorkingDirectory=/home/pi/wyoming-satellite
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Save the file and exit your editor. Next, enable the service to start at boot and run it:
``` sh
sudo systemctl enable --now wyoming-satellite.service
```
(you may need to hit CTRL+C to get back to a shell prompt)
With the service running, you can view logs in real-time with:
``` sh
journalctl -u wyoming-satellite.service -f
```
If needed, disable and stop the service with:
``` sh
sudo systemctl disable --now wyoming-satellite.service
```
## Audio Enhancements
You can run the satellite with automatic gain control and noise suppression:
``` sh
script/run \
... \
--mic-auto-gain 5 \
--mic-noise-suppression 2
```
Automatic gain control is between 0-31 dbFS, which 31 being the loudest.
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
## Local Wake Word Detection
Install the necessary system dependencies:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
libopenblas-dev
```
From your home directory, install the openWakeWord Wyoming service:
```sh
git clone https://github.com/rhasspy/wyoming-openwakeword.git
cd wyoming-openwakeword
script/setup
```
Create a systemd service for it:
``` sh
sudo systemctl edit --force --full wyoming-openwakeword.service
```
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
```text
[Unit]
Description=Wyoming openWakeWord
[Service]
Type=simple
ExecStart=/home/pi/wyoming-openwakeword/script/run --uri 'tcp://127.0.0.1:10400'
WorkingDirectory=/home/pi/wyoming-openwakeword
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Save the file and exit your editor.
You can now update your satellite service:
``` sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Update just the parts below:
```text
[Unit]
...
Requires=wyoming-openwakeword.service
[Service]
...
ExecStart=/home/pi/wyoming-satellite/script/run ... --wake-uri 'tcp://127.0.0.1:10400' --wake-word-name 'ok_nabu'
...
[Install]
...
```
Reload and restart the satellite service:
``` sh
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satellite.service
```
You should see the wake service get automatically loaded:
``` sh
sudo systemctl status wyoming-satellite.service wyoming-openwakeword.service
```
They should all be "active (running)" and green.
Test out your satellite by saying "ok, nabu" and a voice command. Use `journalctl` to check the logs of services for errors:
``` sh
journalctl -u wyoming-openwakeword.service -f
```
Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service.
## LED Service
Example event services for the ReSpeaker 2Mic and 4Mic HATs are included in `wyoming-satellite/examples` that will change the LED color depending on the satellite state. The example below is for the 2Mic HAT, using `2mic_service.py`. If you're using the 4Mic HAT, use `4mic_service.py` instead as the LEDs and GPIO pins are slightly different.
Install it from your home directory:
```sh
cd wyoming-satellite/examples
python3 -m venv --system-site-packages .venv
.venv/bin/pip3 install --upgrade pip
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install 'wyoming==1.5.2'
```
The `--system-site-packages` argument is used to access the pre-installed `gpiozero` and `spidev` Python packages. If these are **not already installed** in your system, run:
```sh
sudo apt-get install python3-spidev python3-gpiozero
```
Test the service with:
```sh
.venv/bin/python3 2mic_service.py --help
```
Create a systemd service for it:
``` sh
sudo systemctl edit --force --full 2mic_leds.service
```
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
```text
[Unit]
Description=2Mic LEDs
[Service]
Type=simple
ExecStart=/home/pi/wyoming-satellite/examples/.venv/bin/python3 2mic_service.py --uri 'tcp://127.0.0.1:10500'
WorkingDirectory=/home/pi/wyoming-satellite/examples
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Save the file and exit your editor.
You can now update your satellite service:
``` sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Update just the parts below:
```text
[Unit]
...
Requires=2mic_leds.service
[Service]
...
ExecStart=/home/pi/wyoming-satellite/script/run ... --event-uri 'tcp://127.0.0.1:10500'
...
[Install]
...
```
Reload and restart the satellite service:
``` sh
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satellite.service
```
You should see the service get automatically loaded:
``` sh
sudo systemctl status wyoming-satellite.service 2mic_leds.service
```
They should all be "active (running)" and green.
Try a voice command and see if the LEDs change. Use `journalctl` to check the logs of services for errors:
``` sh
journalctl -u 2mic_leds.service -f
```
Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service.
+75
View File
@@ -0,0 +1,75 @@
# Tutorial with Installer
Create a voice satellite using a Raspberry Pi 3+ and USB microphone and speakers.
## Install OS
Follow instructions to [install Raspberry Pi OS](https://www.raspberrypi.com/software/). Under "Choose OS", pick "Raspberry Pi OS (other)" and "Raspberry Pi OS (Legacy, **64-bit**) Lite".
When asking if you'd like to apply customization settings, choose "Edit Settings" and:
* Set a username/password
* Configure the wireless LAN
* Under the Services tab, enable SSH and use password authentication
## Install Dependencies
After flashing and booting the satellite, connect to it over SSH using the username/password you configured during flashing.
**On the satellite**, make sure system dependencies are installed:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
git \
python3-venv
```
Clone the `wyoming-satellite` repository:
```sh
git clone https://github.com/rhasspy/wyoming-satellite.git
```
Now you can run the installer:
```sh
cd wyoming-satellite/
python3 -m installer
```
## Satellite
Under the "Satellite" menu, select "Satellite Type" and choose "Local wake word detection" (with the space bar).
## Microphone
Make sure you USB microphone is plugged in.
Under the "Microphone" menu, select "Autodetect", and speak loudly into the microphone for a few seconds.
In "Audio Settings", set "Noise Suppression" to "Medium" and "Auto Gain" to 15.
## Speakers
Make sure your USB speakers are plugged in.
Under the "Speakers" menu, choose "Test All Speakers". For each device, choose "Play Sound" and listen. If you hear the sound, select "Choose This Device"; otherwise, select "Next Device".
In "Toggle Feedback Sounds", enable "On wake-up" and "After voice command" (with the space bar).
## Wake Word
Under the "Wake Word" menu, select "Wake Word System" and choose "openWakeWord" (with the space bar). When prompted, install openWakeWord.
In the "openWakeWord" menu, choose "Download Community Wake Words". Back in the "Wake Word" menu, select "Choose Wake Word" and select one (with the space bar).
## Apply Settings
From the main menu, select "Apply Settings" and enter your password.
Check logs with:
```sh
sudo journalctl -f -u wyoming-satellite.service
```
+66
View File
@@ -0,0 +1,66 @@
#!/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
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.
# We use v6.1 here.
echo 'Downloading source code'
curl -L -o - 'https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v6.1.tar.gz' | \
tar -xzf -
cd seeed-voicecard-6.1/
# 1. Build kernel module
echo 'Building kernel module'
ver='0.3'
mod='seeed-voicecard'
src='./'
kernel="$(uname -r)"
marker='0.0.0'
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}" && {
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
+16 -1
View File
@@ -12,7 +12,13 @@ import gpiozero
import spidev
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted
from wyoming.wake import Detection
@@ -107,6 +113,15 @@ class LEDsEventHandler(AsyncEventHandler):
self.color(_BLACK)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Flash
for _ in range(3):
self.color(_GREEN)
await asyncio.sleep(0.3)
self.color(_BLACK)
await asyncio.sleep(0.3)
elif SatelliteDisconnected.is_type(event.type):
self.color(_RED)
return True
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker 4mic HAT."""
import argparse
import asyncio
import logging
import time
from functools import partial
from math import ceil
from typing import Tuple
import gpiozero
import spidev
from wyoming.asr import Transcript
from wyoming.event import Event
from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
NUM_LEDS = 12
LEDS_GPIO = 5
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],
}
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
#
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
led_power = gpiozero.LED(LEDS_GPIO, active_high=True)
led_power.on()
leds = APA102(num_led=NUM_LEDS)
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args, leds))
except KeyboardInterrupt:
pass
finally:
leds.cleanup()
led_power.off()
# -----------------------------------------------------------------------------
_BLACK = (0, 0, 0)
_WHITE = (255, 255, 255)
_RED = (255, 0, 0)
_YELLOW = (255, 255, 0)
_BLUE = (0, 0, 255)
_GREEN = (0, 255, 0)
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
leds: "APA102",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
self.leds = leds
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if StreamingStarted.is_type(event.type):
self.color(_YELLOW)
elif Detection.is_type(event.type):
self.color(_BLUE)
await asyncio.sleep(1.0) # show for 1 sec
elif VoiceStarted.is_type(event.type):
self.color(_YELLOW)
elif Transcript.is_type(event.type):
self.color(_GREEN)
await asyncio.sleep(1.0) # show for 1 sec
elif StreamingStopped.is_type(event.type):
self.color(_BLACK)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Flash
for _ in range(3):
self.color(_GREEN)
await asyncio.sleep(0.3)
self.color(_BLACK)
await asyncio.sleep(0.3)
elif SatelliteDisconnected.is_type(event.type):
self.color(_RED)
return True
def color(self, rgb: Tuple[int, int, int]) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2])
self.leds.show()
# -----------------------------------------------------------------------------
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=MAX_BRIGHTNESS,
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
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
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,
)
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
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+1
View File
@@ -0,0 +1 @@
"""Command-line installer."""
+178
View File
@@ -0,0 +1,178 @@
"""Command-line installer."""
import logging
from typing import List, Optional
from .const import LOCAL_DIR, PROGRAM_DIR, SatelliteType, Settings
from .drivers import install_drivers
from .microphone import configure_microphone
from .packages import (
can_import,
install_packages,
install_packages_nogui,
packages_installed,
)
from .satellite import configure_satellite
from .services import generate_services, install_services, stop_services
from .speakers import configure_speakers
from .wake_word import configure_wake_word
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
_LOGGER = logging.getLogger()
def main() -> None:
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
filename=LOCAL_DIR / "installer.log", filemode="w", level=logging.DEBUG
)
settings = Settings.load()
if not packages_installed("whiptail"):
install_packages_nogui("whiptail")
choice: Optional[str] = None
while True:
choice = main_menu(choice)
if choice == "satellite":
configure_satellite(settings)
elif choice == "microphone":
configure_microphone(settings)
elif choice == "speakers":
configure_speakers(settings)
elif choice == "wake":
configure_wake_word(settings)
elif choice == "drivers":
install_drivers(settings)
elif choice == "apply":
apply_settings(settings)
else:
break
def main_menu(last_choice: Optional[str]) -> Optional[str]:
items: List[ItemType] = [
("satellite", "Satellite"),
("microphone", "Microphone"),
("speakers", "Speakers"),
("wake", "Wake Word"),
("drivers", "Drivers"),
("apply", "Apply Settings"),
]
return menu(
"Main",
items,
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Exit"],
)
# -----------------------------------------------------------------------------
def pip_install(*args) -> List[str]:
return [
str(PROGRAM_DIR / ".venv" / "bin" / "pip3"),
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
] + list(args)
def apply_settings(settings: Settings) -> None:
if settings.mic.device is None:
msgbox("Please configure microphone")
return
if (settings.satellite.type == SatelliteType.WAKE) and (
settings.wake.system is None
):
msgbox("Please set wake word system")
return
password: Optional[str] = None
if not packages_installed("python3-pip", "python3-venv"):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing Python packages...", password, "python3-pip", "python3-venv"
)
if not success:
error("installing pip/venv for Python")
return
# Satellite venv
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
result = run_with_gauge(
"Creating virtual environment...", [str(PROGRAM_DIR / "script" / "setup")]
)
if not result:
error("creating virtual environment")
return
# silero (vad)
if (settings.satellite.type == SatelliteType.VAD) and (
not can_import("pysilero_vad")
):
result = run_with_gauge(
"Installing vad...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_vad.txt"))],
)
if not result:
error("installing vad")
return
# webrtc (audio enhancements)
if ((settings.mic.noise_suppression > 0) or (settings.mic.auto_gain > 0)) and (
not can_import("webrtc_noise_gain")
):
result = run_with_gauge(
"Installing audio enhancements...",
[
pip_install(
"-r", str(PROGRAM_DIR / "requirements_audio_enhancement.txt")
)
],
)
if not result:
error("installing audio enhancements")
return
if (
settings.satellite.event_service_command
and (
("2mic" in settings.satellite.event_service_command)
or ("4mic" in settings.satellite.event_service_command)
)
) and (not can_import("gpiozero", "spidev")):
result = run_with_gauge(
"Installing event requirements...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))],
)
if not result:
error("installing event requirements")
return
generate_services(settings)
if password is None:
password = passwordbox("sudo password:")
if not password:
return
stop_services(password)
install_services(settings, password)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
"""Constants and dataclasses."""
import json
import logging
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import List, Optional
from .dataclasses_json import DataClassJsonMixin
_DIR = Path(__file__).parent
_LOGGER = logging.getLogger()
PROGRAM_DIR = _DIR.parent
LOCAL_DIR = PROGRAM_DIR / "local"
SETTINGS_PATH = LOCAL_DIR / "settings.json"
SERVICES_DIR = LOCAL_DIR / "services"
RECORD_SECONDS = 5
RECORD_RMS_MIN = 30
TITLE = "Wyoming Satellite"
WIDTH = "75"
HEIGHT = "20"
LIST_HEIGHT = "12"
class SatelliteType(str, Enum):
ALWAYS_STREAMING = "always"
VAD = "vad"
WAKE = "wake"
@dataclass
class SatelliteSettings(DataClassJsonMixin):
name: str = "Wyoming Satellite"
type: SatelliteType = SatelliteType.ALWAYS_STREAMING
debug: bool = False
event_service_command: Optional[List[str]] = None
@dataclass
class MicrophoneSettings(DataClassJsonMixin):
device: Optional[str] = None
noise_suppression: int = 0
auto_gain: int = 0
volume_multiplier: float = 1.0
@dataclass
class SpeakerSettings(DataClassJsonMixin):
device: Optional[str] = None
volume_multiplier: float = 1.0
feedback_sounds: List[str] = field(default_factory=list)
@dataclass
class OpenWakeWordSettings(DataClassJsonMixin):
wake_word: str = "ok_nabu"
threshold: float = 0.5
trigger_level: int = 1
@dataclass
class Porcupine1Settings(DataClassJsonMixin):
wake_word: str = "porcupine"
sensitivity: float = 0.5
@dataclass
class SnowboySettings(DataClassJsonMixin):
wake_word: str = "snowboy"
sensitivity: float = 0.5
class WakeWordSystem(str, Enum):
OPENWAKEWORD = "openWakeWord"
PORCUPINE1 = "porcupine1"
SNOWBOY = "snowboy"
@dataclass
class WakeWordSettings(DataClassJsonMixin):
system: Optional[WakeWordSystem] = None
openwakeword: OpenWakeWordSettings = field(default_factory=OpenWakeWordSettings)
porcupine1: Porcupine1Settings = field(default_factory=Porcupine1Settings)
snowboy: SnowboySettings = field(default_factory=SnowboySettings)
@dataclass
class Settings(DataClassJsonMixin):
satellite: SatelliteSettings = field(default_factory=SatelliteSettings)
mic: MicrophoneSettings = field(default_factory=MicrophoneSettings)
snd: SpeakerSettings = field(default_factory=SpeakerSettings)
wake: WakeWordSettings = field(default_factory=WakeWordSettings)
@staticmethod
def load() -> "Settings":
if SETTINGS_PATH.exists():
_LOGGER.debug("Loading settings from %s", SETTINGS_PATH)
with open(SETTINGS_PATH, "r", encoding="utf-8") as settings_file:
settings_dict = json.load(settings_file)
return Settings.from_dict(settings_dict)
return Settings()
def save(self) -> None:
_LOGGER.debug("Saving settings to %s", SETTINGS_PATH)
settings_dict = self.to_dict()
SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_PATH, "w", encoding="utf-8") as settings_file:
json.dump(settings_dict, settings_file, ensure_ascii=False, indent=2)
+70
View File
@@ -0,0 +1,70 @@
"""Implement a tiny subset of dataclasses_json for config."""
from collections.abc import Mapping, Sequence
from dataclasses import asdict, fields, is_dataclass
from typing import Any, Dict, Type
class DataClassJsonMixin:
"""Adds from_dict to dataclass."""
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Any:
"""Parse dataclasses recursively."""
kwargs: Dict[str, Any] = {}
cls_fields = {field.name: field for field in fields(cls)}
for key, value in data.items():
if key not in cls_fields:
# Skip unknown fields
continue
field = cls_fields[key]
if is_dataclass(field.type):
assert issubclass(field.type, DataClassJsonMixin), field.type
kwargs[key] = field.type.from_dict(value)
else:
kwargs[key] = _decode(value, field.type)
# Fill in optional fields with None
for field in cls_fields.values():
if (field.name not in kwargs) and _is_optional(field.type):
kwargs[field.name] = None
return cls(**kwargs)
def to_dict(self) -> Dict[str, Any]:
"""Alias for asdict."""
return asdict(self)
def _decode(value: Any, target_type: Type) -> Any:
"""Decode value using (possibly generic) type."""
if is_dataclass(target_type):
assert issubclass(target_type, DataClassJsonMixin), target_type
return target_type.from_dict(value) if value is not None else None
if hasattr(target_type, "__args__"):
# Optional[T]
if type(None) in target_type.__args__:
optional_type = target_type.__args__[0]
return _decode(value, optional_type)
# List[T]
if isinstance(value, Sequence):
list_type = target_type.__args__[0]
return [_decode(item, list_type) for item in value]
# Dict[str, T]
if isinstance(value, Mapping):
value_type = target_type.__args__[1]
return {
map_key: _decode(map_value, value_type)
for map_key, map_value in value.items()
}
return value
def _is_optional(target_type: Type):
"""True if type is Optional"""
return hasattr(target_type, "__args__") and (type(None) in target_type.__args__)
+50
View File
@@ -0,0 +1,50 @@
"""Hardware drivers."""
import sys
from typing import Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import error, menu, msgbox, passwordbox, run_with_gauge, yesno
def install_drivers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Drivers",
[("respeaker", "Install ReSpeaker Drivers")],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
if yesno(
"ReSpeaker drivers for the Raspberry Pi will now be compiled and installed. "
"This will take a long time and require a reboot. "
"Continue?"
):
password = passwordbox("sudo password:")
success = run_with_gauge(
"Installing drivers...",
[
[
"sudo",
"-S",
str(PROGRAM_DIR / "etc" / "install-respeaker-drivers.sh"),
]
],
sudo_password=password,
)
if not success:
error("installing ReSpeaker drivers")
return
msgbox(
"Driver installation complete. "
"Please reboot your Raspberry Pi and re-run the installer. "
'Once rebooted, select the "seeed" microphone.'
)
sys.exit(0)
else:
break
+214
View File
@@ -0,0 +1,214 @@
"""Microphone settings."""
import array
import logging
import math
import subprocess
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Dict, List, Optional
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
from .whiptail import gauge, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_microphone(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = microphone_menu(choice)
if choice == "detect":
# Automatically detect microphone with hightest RMS
best_device: Optional[str] = None
best_rms: Optional[float] = None
devices = get_microphone_devices()
with ThreadPoolExecutor() as executor:
futures: Dict[str, Future] = {}
for device in devices:
futures[device] = executor.submit(_record_proc, device)
gauge("Speak loudly into the microphone.", RECORD_SECONDS)
for device, future in futures.items():
device_rms = future.result()
if device_rms is None:
_LOGGER.warning("Failed to record from microphone %s", device)
continue
_LOGGER.debug(
"Microphone %s got RMS %s (min: %s)",
device,
device_rms,
RECORD_RMS_MIN,
)
if device_rms < RECORD_RMS_MIN:
continue
if (best_rms is None) or (device_rms > best_rms):
best_device = device
best_rms = device_rms
if best_device is not None:
msgbox(f"Successfully detected microphone: {best_device}")
settings.mic.device = best_device
settings.save()
else:
msgbox(
"Audio was not detected from any microphone.\n"
"If a satellite is currently running, you may need to stop it."
)
elif choice == "list":
# arecord -L
microphone_device = radiolist(
"Select ALSA Device:", get_microphone_devices(), settings.mic.device
)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "manual":
microphone_device = inputbox("Enter ALSA Device:", settings.mic.device)
if microphone_device:
settings.mic.device = microphone_device
settings.save()
elif choice == "settings":
configure_audio_settings(settings)
else:
break
def microphone_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone",
[
("detect", "Autodetect"),
("list", "Select From List"),
("manual", "Enter Manually"),
("settings", "Audio Settings"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def get_microphone_devices() -> List[str]:
devices = []
lines = subprocess.check_output(["arecord", "-L"]).decode("utf-8").splitlines()
for line in lines:
line = line.strip()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def _record_proc(device: str) -> Optional[float]:
try:
proc = subprocess.Popen(
[
"arecord",
"-q",
"-D",
device,
"-r",
"16000",
"-c",
"1",
"-f",
"S16_LE",
"-t",
"raw",
"-d",
str(RECORD_SECONDS),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
audio, stderr = proc.communicate()
if proc.returncode != 0:
_LOGGER.error(
"Error recording from device %s: %s", device, stderr.decode("utf-8")
)
return None
# 16-bit mono
audio_array = array.array("h", audio)
rms = math.sqrt(
(1 / len(audio_array) * sum(x * x for x in audio_array.tolist()))
)
return rms
except Exception:
_LOGGER.exception("Error recording from device: %s", device)
return None
return 0
def configure_audio_settings(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = audio_settings_menu(choice)
if choice == "noise":
noise_suppression = radiolist(
"Noise Suppression Level",
[(0, "Off"), (1, "Low"), (2, "Medium"), (3, "High"), (4, "Maximum")],
settings.mic.noise_suppression,
)
if noise_suppression is not None:
settings.mic.noise_suppression = noise_suppression
settings.save()
elif choice == "gain":
while True:
auto_gain = inputbox("Auto Gain (0-31 dbFS)", settings.mic.auto_gain)
if auto_gain is None:
break
try:
auto_gain_int = int(auto_gain)
except ValueError:
msgbox("Invalid value")
continue
if 0 <= auto_gain_int <= 31:
settings.mic.auto_gain = auto_gain_int
settings.save()
break
msgbox("Must be 0-31")
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.mic.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.mic.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def audio_settings_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Microphone > Audio Settings",
[
("noise", "Noise Suppression"),
("gain", "Auto Gain"),
("multiplier", "Volume Multiplier"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
+87
View File
@@ -0,0 +1,87 @@
"""Install system packages."""
import logging
import subprocess
from .const import PROGRAM_DIR
from .whiptail import run_with_gauge
_LOGGER = logging.getLogger()
def packages_installed(*packages) -> bool:
for package in packages:
try:
subprocess.check_call(
["dpkg", "--status", str(package)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
def install_packages_nogui(*packages, update: bool = True) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "apt-get", "update"])
commands.append(
["sudo", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
try:
for command in commands:
subprocess.check_call(
command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
except Exception:
_LOGGER.exception("Unexpected error installing packages: %s", packages)
return False
return True
def install_packages(
text: str, sudo_password: str, *packages, update: bool = True
) -> bool:
assert packages, "No packages"
commands = []
if update:
commands.append(["sudo", "-S", "apt-get", "update"])
commands.append(
["sudo", "-S", "apt-get", "install", "--yes"] + [str(p) for p in packages]
)
return run_with_gauge(text, commands, sudo_password=sudo_password)
def can_import(*names) -> bool:
assert names, "No names"
venv_dir = PROGRAM_DIR / ".venv"
if not venv_dir.exists():
return False
try:
import venv
except ImportError:
return False
context = venv.EnvBuilder().ensure_directories(PROGRAM_DIR / ".venv")
try:
subprocess.check_call(
[context.env_exe, "-c", "; ".join(f"import {name}" for name in names)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
return False
return True
+120
View File
@@ -0,0 +1,120 @@
"""Satellite settings."""
from typing import Optional
from .const import PROGRAM_DIR, SatelliteType, Settings
from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge
def configure_satellite(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = satellite_menu(choice)
if choice == "name":
name = inputbox("Satellite Name:", settings.satellite.name)
if name:
settings.satellite.name = name
settings.save()
elif choice == "type":
satellite_type = radiolist(
"Satellite Type:",
[
(SatelliteType.ALWAYS_STREAMING, "Always streaming"),
(SatelliteType.VAD, "Voice activity detection"),
(SatelliteType.WAKE, "Local wake word detection"),
],
settings.satellite.type,
)
if satellite_type is not None:
settings.satellite.type = SatelliteType(satellite_type)
settings.save()
elif choice == "feedback":
configure_feedback(settings)
elif choice in ("restart", "stop", "start"):
password = passwordbox("sudo password:")
if not password:
continue
command = ["sudo", "-S", "systemctl", choice, "wyoming-satellite.service"]
text = {"restart": "Restarting", "stop": "Stopping", "start": "Starting"}[
choice
]
success = run_with_gauge(
f"{text} satellite...", [command], sudo_password=password
)
if not success:
error(f"{text.lower()} satellite")
elif choice == "debug":
debug = radiolist(
"Debug Mode:",
[
("disabled", "Disabled"),
("enabled", "Enabled"),
],
"enabled" if settings.satellite.debug else "disabled",
)
if debug is not None:
settings.satellite.debug = debug == "enabled"
settings.save()
else:
break
def satellite_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Satellite",
[
("name", "Satellite Name"),
("type", "Satellite Type"),
("feedback", "Feedback"),
("restart", "Restart Services"),
("stop", "Stop Services"),
("start", "Start Services"),
("debug", "Set Debug Mode"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def configure_feedback(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Satellite > Feedback",
[("respeaker", "ReSpeaker")],
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "respeaker":
selected_service: Optional[str] = None
if settings.satellite.event_service_command:
if "2mic" in settings.satellite.event_service_command[0]:
selected_service = "2mic"
elif "4mic" in settings.satellite.event_service_command[0]:
selected_service = "4mic"
event_service = radiolist(
"Event Service:",
[
("none", "None"),
("2mic", "2mic LEDs"),
("4mic", "4mic LEDs"),
],
selected_service,
)
if event_service is not None:
if event_service == "none":
settings.satellite.event_service_command = None
else:
settings.satellite.event_service_command = [
str(PROGRAM_DIR / "script" / f"run_{event_service}"),
"--uri",
"tcp://127.0.0.1:10500",
]
settings.save()
else:
break
+262
View File
@@ -0,0 +1,262 @@
"""Systemd service management."""
import shlex
import subprocess
from pathlib import Path
from typing import List
from .const import (
LOCAL_DIR,
PROGRAM_DIR,
SERVICES_DIR,
SatelliteType,
Settings,
WakeWordSystem,
)
from .whiptail import error, msgbox, run_with_gauge
def stop_services(password: str) -> None:
stop_commands = []
for service in ("satellite", "wakeword", "event"):
service_filename = f"wyoming-{service}.service"
service_path = Path("/etc/systemd/system") / service_filename
if not service_path.exists():
continue
stop_commands.append(["sudo", "-S", "systemctl", "stop", service_filename])
stop_commands.append(["sudo", "-S", "systemctl", "disable", service_filename])
run_with_gauge("Stopping Services...", stop_commands, sudo_password=password)
def generate_services(settings: Settings) -> None:
SERVICES_DIR.mkdir(parents=True, exist_ok=True)
user_id = subprocess.check_output(["id", "-u"], text=True).strip()
user_name = subprocess.check_output(["id", "--name", "-u"], text=True).strip()
satellite_command: List[str] = [
str(PROGRAM_DIR / "script" / "run"),
"--name",
settings.satellite.name,
"--uri",
"tcp://0.0.0.0:10700",
"--mic-command",
f"arecord -D {settings.mic.device} -q -r 16000 -c 1 -f S16_LE -t raw",
]
satellite_requires: List[str] = []
if settings.snd.device is not None:
# Audio output
satellite_command.extend(
[
"--snd-command",
f"aplay -D {settings.snd.device} -q -r 22050 -c 1 -f S16_LE -t raw",
]
)
if settings.snd.volume_multiplier != 1.0:
satellite_command.extend(
["--snd-volume-multiplier", str(settings.snd.volume_multiplier)]
)
for sound_name in settings.snd.feedback_sounds:
# Try local/sounds first
sound_path = LOCAL_DIR / "sounds" / f"{sound_name}.wav"
if not sound_path.exists():
sound_path = PROGRAM_DIR / "sounds" / f"{sound_name}.wav"
satellite_command.extend([f"--{sound_name}-wav", str(sound_path)])
if settings.mic.noise_suppression > 0:
satellite_command.extend(
["--mic-noise-suppression", str(settings.mic.noise_suppression)]
)
if settings.mic.auto_gain > 0:
satellite_command.extend(["--mic-auto-gain", str(settings.mic.auto_gain)])
if settings.mic.volume_multiplier != 1.0:
satellite_command.extend(
["--mic-volume-multiplier", str(settings.mic.volume_multiplier)]
)
if settings.satellite.type == SatelliteType.VAD:
# Voice activity detect
satellite_command.append("--vad")
elif settings.satellite.type == SatelliteType.WAKE:
# Local wake word detection
assert settings.wake.system is not None, "Wake word system not set"
wake_word_service = "wyoming-wakeword"
wake_word_dir = LOCAL_DIR / (
"wyoming-" + WakeWordSystem(settings.wake.system).value.lower()
)
wake_word_command = [
str(wake_word_dir / "script" / "run"),
"--uri",
"tcp://127.0.0.1:10400",
]
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
wake_word = settings.wake.openwakeword.wake_word
wake_word_command.extend(
[
"--threshold",
str(settings.wake.openwakeword.threshold),
"--trigger-level",
str(settings.wake.openwakeword.trigger_level),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "openWakeWord"),
]
)
elif settings.wake.system == WakeWordSystem.PORCUPINE1:
wake_word = settings.wake.porcupine1.wake_word
wake_word_command.extend(
["--sensitivity", str(settings.wake.porcupine1.sensitivity)]
)
elif settings.wake.system == WakeWordSystem.SNOWBOY:
wake_word = settings.wake.snowboy.wake_word
wake_word_command.extend(
[
"--sensitivity",
str(settings.wake.snowboy.sensitivity),
"--custom-model-dir",
str(LOCAL_DIR / "custom-wake-words" / "snowboy"),
]
)
else:
raise ValueError(settings.wake.system)
if settings.satellite.debug:
wake_word_command.append("--debug")
wake_word_command_str = shlex.join(wake_word_command)
with open(
SERVICES_DIR / f"{wake_word_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print(
f"Description={WakeWordSystem(settings.wake.system).value}",
file=service_file,
)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={wake_word_command_str}", file=service_file)
print(f"WorkingDirectory={wake_word_dir}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(
[
"--wake-uri",
"tcp://127.0.0.1:10400",
"--wake-word-name",
wake_word,
]
)
satellite_requires.append(f"{wake_word_service}.service")
if settings.satellite.event_service_command:
event_service = "wyoming-event"
event_command_str = shlex.join(settings.satellite.event_service_command)
with open(
SERVICES_DIR / f"{event_service}.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Event service", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
print(f"ExecStart={event_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
satellite_command.extend(["--event-uri", "tcp://127.0.0.1:10500"])
satellite_requires.append(f"{event_service}.service")
if settings.satellite.debug:
satellite_command.extend(
["--debug", "--debug-recording-dir", str(LOCAL_DIR / "debug-recording")]
)
satellite_command_str = shlex.join(satellite_command)
with open(
SERVICES_DIR / "wyoming-satellite.service", "w", encoding="utf-8"
) as service_file:
print("[Unit]", file=service_file)
print("Description=Wyoming Satellite", file=service_file)
print("Wants=network-online.target", file=service_file)
print("After=network-online.target", file=service_file)
for requires in satellite_requires:
print(f"Requires={requires}", file=service_file)
print("", file=service_file)
print("[Service]", file=service_file)
print("Type=simple", file=service_file)
print(f"User={user_name}", file=service_file)
# For PulseAudio
print(f"Environment=XDG_RUNTIME_DIR=/run/user/{user_id}", file=service_file)
print(f"ExecStart={satellite_command_str}", file=service_file)
print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file)
print("Restart=always", file=service_file)
print("RestartSec=1", file=service_file)
print("", file=service_file)
print("[Install]", file=service_file)
print("WantedBy=default.target", file=service_file)
def install_services(settings: Settings, password: str):
# Install and run
installed_services = ["satellite"]
if settings.satellite.type == SatelliteType.WAKE:
assert settings.wake.system is not None, "No wake word system"
installed_services.append("wakeword")
if settings.satellite.event_service_command:
installed_services.append("event")
install_commands = []
for service in installed_services:
service_filename = f"wyoming-{service}.service"
install_commands.append(
[
"sudo",
"-S",
"cp",
str(SERVICES_DIR / service_filename),
"/etc/systemd/system/",
]
)
install_commands.append(["sudo", "-S", "systemctl", "daemon-reload"])
# Copy first, then enable and start
for service in installed_services:
install_commands.append(["sudo", "-S", "systemctl", "enable", service_filename])
install_commands.append(
["sudo", "-S", "systemctl", "enable", "--now", "wyoming-satellite.service"]
)
success = run_with_gauge(
"Installing Services...", install_commands, sudo_password=password
)
if success:
msgbox("Successfully installed services")
else:
error("installing services")
+150
View File
@@ -0,0 +1,150 @@
"""Speaker settings."""
import logging
import subprocess
from typing import List, Optional
from .const import PROGRAM_DIR, Settings
from .whiptail import checklist, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger()
def configure_speakers(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = speakers_menu(choice)
if choice == "play":
if settings.snd.device is None:
msgbox("No speaker device set")
continue
test_sound_device(settings.snd.device)
elif choice == "test":
sound_device = test_speakers()
if sound_device is not None:
settings.snd.device = sound_device
settings.save()
elif choice == "list":
sound_device = radiolist(
"Select ALSA Device:",
get_sound_devices(),
settings.snd.device,
)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "manual":
sound_device = inputbox("Enter ALSA Device:", settings.snd.device)
if sound_device:
settings.snd.device = sound_device
settings.save()
elif choice == "disable":
settings.snd.device = None
settings.save()
msgbox("Sound disabled")
elif choice == "feedback":
feedback_sounds = checklist(
"Enabled Sounds:",
[("awake", "On wake-up"), ("done", "After voice command")],
settings.snd.feedback_sounds,
)
if feedback_sounds is not None:
settings.snd.feedback_sounds = feedback_sounds
settings.save()
elif choice == "multiplier":
while True:
volume_multiplier = inputbox(
"Volume Multiplier (1 = default)", settings.snd.volume_multiplier
)
if volume_multiplier is None:
break
try:
volume_multiplier_float = float(volume_multiplier)
except ValueError:
msgbox("Invalid value")
continue
if volume_multiplier_float > 0:
settings.snd.volume_multiplier = volume_multiplier_float
settings.save()
break
msgbox("Must be > 0")
else:
break
def speakers_menu(last_choice: Optional[str]) -> Optional[str]:
return menu(
"Main > Speakers",
[
("play", "Play Sound"),
("test", "Test All Speakers"),
("list", "Select From List"),
("manual", "Enter Manually"),
("disable", "Disable Sound"),
("multiplier", "Volume Multiplier"),
("feedback", "Toggle Feedback Sounds"),
],
selected_item=last_choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
def get_sound_devices() -> List[str]:
devices = []
lines = subprocess.check_output(["aplay", "-L"]).decode("utf-8").splitlines()
for line in lines:
line = line.strip()
# default = PulseAudio
if (line == "default") or line.startswith("plughw:"):
devices.append(line)
return devices
def test_speakers() -> Optional[str]:
devices = get_sound_devices()
if not devices:
msgbox("No speakers found")
return None
device = devices.pop()
while True:
choice = menu(
f"Device: {device}",
[
("play", "Play Sound"),
("next", "Next Device"),
("choose", "Choose This Device"),
],
)
if choice == "play":
test_sound_device(device)
elif choice == "next":
if not devices:
msgbox("No more devices")
break
device = devices.pop()
elif choice == "choose":
return device
else:
break
return None
def test_sound_device(device: str) -> None:
try:
subprocess.check_call(
["aplay", "-q", "-D", device, str(PROGRAM_DIR / "sounds" / "awake.wav")]
)
except Exception:
_LOGGER.exception("Error testing device: %s", device)
+468
View File
@@ -0,0 +1,468 @@
"""Wake word settings."""
import itertools
import re
import shutil
from pathlib import Path
from typing import Dict, Optional
from .const import LOCAL_DIR, SatelliteType, Settings, WakeWordSystem
from .packages import install_packages, packages_installed
from .whiptail import (
error,
inputbox,
menu,
msgbox,
passwordbox,
radiolist,
run_with_gauge,
yesno,
)
def configure_wake_word(settings: Settings) -> None:
if settings.satellite.type != SatelliteType.WAKE:
if not yesno("Set satellite type to local wake word?"):
return
settings.satellite.type = SatelliteType.WAKE
settings.save()
def star(ww_system: WakeWordSystem) -> str:
return (
f"{ww_system.value} [*]"
if settings.wake.system == ww_system
else ww_system.value
)
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word",
[
("system", "Wake Word System"),
("wake_word", "Choose Wake Word"),
("openWakeWord", star(WakeWordSystem.OPENWAKEWORD)),
("porcupine1", star(WakeWordSystem.PORCUPINE1)),
("snowboy", star(WakeWordSystem.SNOWBOY)),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "system":
wake_word_system = radiolist(
"Wake Word System:",
[v.value for v in WakeWordSystem],
settings.wake.system,
)
if wake_word_system is None:
continue
wake_word_system = WakeWordSystem(wake_word_system)
install_wake_word(settings, wake_word_system)
elif choice == "wake_word":
select_wake_word(settings)
elif choice == "openWakeWord":
configure_openWakeWord(settings)
elif choice == "porcupine1":
configure_porcupine1(settings)
elif choice == "snowboy":
configure_snowboy(settings)
else:
break
def install_wake_word(settings: Settings, wake_word_system: WakeWordSystem) -> None:
if wake_word_system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
if not yesno("Install openWakeWord?"):
return
success = run_with_gauge(
"Installing openWakeWord",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-openwakeword.git",
str(oww_dir),
],
[str(oww_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(oww_dir)
except Exception:
pass
error("installing openWakeWord")
return
msgbox("openWakeWord installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
if not yesno("Install porcupine1?"):
return
success = run_with_gauge(
"Installing porcupine1",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-porcupine1.git",
str(porcupine1_dir),
],
[str(porcupine1_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(porcupine1_dir)
except Exception:
pass
error("installing porcupine1")
return
msgbox("porcupine1 installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
if wake_word_system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
if not yesno("Install snowboy?"):
return
snowboy_packages = ["python3-dev", "swig", "libatlas-base-dev"]
if not packages_installed(*snowboy_packages):
password = passwordbox("sudo password:")
if not password:
return
success = install_packages(
"Installing system packages...", password, *snowboy_packages
)
if not success:
error("installing " + ", ".join(snowboy_packages))
return
success = run_with_gauge(
"Installing snowboy",
[
[
"git",
"clone",
"https://github.com/rhasspy/wyoming-snowboy.git",
str(snowboy_dir),
],
[str(snowboy_dir / "script" / "setup")],
],
)
if not success:
# Clean up
try:
shutil.rmtree(snowboy_dir)
except Exception:
pass
error("installing snowboy")
return
msgbox("snowboy installed successfully")
settings.wake.system = wake_word_system
settings.save()
return
def select_wake_word(settings: Settings) -> None:
if settings.wake.system == WakeWordSystem.OPENWAKEWORD:
oww_dir = LOCAL_DIR / "wyoming-openwakeword"
if not oww_dir.exists():
msgbox("openWakeWord is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "openWakeWord"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
while True:
ww_paths: Dict[str, Path] = {
p.stem: p for p in custom_wake_word_dir.glob("*.tflite")
}
for ww_path in community_wake_word_dir.rglob("*.tflite"):
ww_name = ww_path.stem
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
for ww_path in (oww_dir / "wyoming_openwakeword" / "models").glob(
"*.tflite"
):
if not re.match("^.+_v[0-9].*$", ww_path.stem):
continue
ww_name = ww_path.stem.rsplit("_", maxsplit=1)[0]
if ww_name in ww_paths:
continue
ww_paths[ww_name] = ww_path
items = sorted(list(ww_paths.keys()))
wake_word = radiolist(
"Wake Word:", items, settings.wake.openwakeword.wake_word
)
if wake_word is None:
break
wake_word_path = ww_paths[wake_word]
if wake_word_path.is_relative_to(community_wake_word_dir):
# Copy to custom directory
shutil.copy(wake_word_path, custom_wake_word_dir)
settings.wake.openwakeword.wake_word = wake_word
settings.save()
break
return
if settings.wake.system == WakeWordSystem.PORCUPINE1:
porcupine1_dir = LOCAL_DIR / "wyoming-porcupine1"
if not porcupine1_dir.exists():
msgbox("porcupine1 is not installed")
return
ww_names = sorted(
list(
set(
p.stem.rsplit("_", maxsplit=1)[0]
for p in (
porcupine1_dir / "wyoming_porcupine1" / "data" / "resources"
).rglob("*.ppn")
)
)
)
wake_word = radiolist(
"Wake Word:", ww_names, settings.wake.porcupine1.wake_word
)
if wake_word is not None:
settings.wake.porcupine1.wake_word = wake_word
settings.save()
return
if settings.wake.system == WakeWordSystem.SNOWBOY:
snowboy_dir = LOCAL_DIR / "wyoming-snowboy"
if not snowboy_dir.exists():
msgbox("snowboy is not installed")
return
custom_wake_word_dir = LOCAL_DIR / "custom-wake-words" / "snowboy"
custom_wake_word_dir.mkdir(parents=True, exist_ok=True)
builtin_wake_words = (snowboy_dir / "wyoming_snowboy" / "data").glob("*.umdl")
custom_wake_words = itertools.chain(
custom_wake_word_dir.glob("*.pmdl"), custom_wake_word_dir.glob("*.umdl")
)
ww_names = sorted(
list(
set(
p.stem
for p in itertools.chain(builtin_wake_words, custom_wake_words)
)
)
)
wake_word = radiolist("Wake Word:", ww_names, settings.wake.snowboy.wake_word)
if wake_word is not None:
settings.wake.snowboy.wake_word = wake_word
settings.save()
return
def configure_openWakeWord(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > openWakeWord",
[
("community", "Download Community Wake Words"),
("threshold", "Set Threshold"),
("trigger_level", "Set Trigger Level"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "community":
community_wake_word_dir = LOCAL_DIR / "home-assistant-wakewords-collection"
if not community_wake_word_dir.exists():
success = run_with_gauge(
"Downloading community wake words...",
[
[
"git",
"clone",
"https://github.com/fwartner/home-assistant-wakewords-collection.git",
str(community_wake_word_dir),
]
],
)
if not success:
error("downloading community wake words")
else:
success = run_with_gauge(
"Updating community wake words...",
[
[
"git",
"-C",
str(community_wake_word_dir),
"pull",
"origin",
"main",
]
],
)
if not success:
error("updating community wake words")
elif choice == "threshold":
while True:
threshold = inputbox(
"Threshold (0-1, 0.5 = default):",
settings.wake.openwakeword.threshold,
)
if threshold is None:
break
try:
threshold_float = float(threshold)
except ValueError:
msgbox("Invalid value")
continue
if 0 < threshold_float < 1:
settings.wake.openwakeword.threshold = threshold_float
settings.save()
break
msgbox("Threshold must be in (0, 1)")
elif choice == "trigger_level":
while True:
trigger_level = inputbox(
"Trigger Level (> 0, 1 = default):",
settings.wake.openwakeword.trigger_level,
)
if trigger_level is None:
break
try:
trigger_level_int = int(trigger_level)
except ValueError:
msgbox("Invalid value")
continue
if trigger_level_int > 0:
settings.wake.openwakeword.trigger_level = trigger_level_int
settings.save()
break
msgbox("Trigger level must be > 0")
else:
break
def configure_porcupine1(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > porcupine1",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.porcupine1.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.porcupine1.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
def configure_snowboy(settings: Settings) -> None:
choice: Optional[str] = None
while True:
choice = menu(
"Main > Wake Word > snowboy",
[
("sensitivity", "Set Sensitivity"),
],
selected_item=choice,
menu_args=["--ok-button", "Select", "--cancel-button", "Back"],
)
if choice == "sensitivity":
while True:
sensitivity = inputbox(
"Sensitivity (0-1, 0.5 = default):",
settings.wake.snowboy.sensitivity,
)
if sensitivity is None:
break
try:
sensitivity_float = float(sensitivity)
except ValueError:
msgbox("Invalid value")
continue
if 0 < sensitivity_float < 1:
settings.wake.snowboy.sensitivity = sensitivity_float
settings.save()
break
msgbox("Sensitivity must be in (0, 1)")
else:
break
+253
View File
@@ -0,0 +1,253 @@
"""Python interface to whiptail command."""
import logging
import shlex
import subprocess
import time
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional, Tuple, Union
from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
ItemType = Union[str, Tuple[Any, str]]
_LOGGER = logging.getLogger()
def whiptail(*args) -> Optional[str]:
proc = subprocess.Popen(
["whiptail", "--title", TITLE] + list(args),
stderr=subprocess.PIPE,
)
_stdout, stderr = proc.communicate()
if proc.returncode != 0:
return None
return stderr.decode("utf-8")
def menu(
text: str,
items: Sequence[ItemType],
selected_item: Optional[str] = None,
menu_args: Optional[Sequence[str]] = None,
) -> Optional[str]:
assert items, "No items"
item_map: Dict[Optional[str], str] = {}
item_args: List[str] = []
selected_tag: Optional[str] = None
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if selected_item == item:
selected_tag = item_id
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if selected_item == item[0]:
selected_tag = item_id
menu_args = list(menu_args) if menu_args is not None else []
if selected_tag is not None:
menu_args.extend(["--default-item", selected_tag])
result = whiptail(
"--notags", *menu_args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
return item_map.get(result)
def inputbox(text: str, init: Optional[Any] = None) -> Optional[str]:
return whiptail(
"--inputbox", text, HEIGHT, WIDTH, str(init) if init is not None else ""
)
def passwordbox(text: str) -> Optional[str]:
return whiptail("--passwordbox", text, HEIGHT, WIDTH)
def radiolist(
text: str,
items: Sequence[ItemType],
selected_item: Any,
*args,
) -> Optional[Any]:
assert items, "No items"
item_map: Dict[str, ItemType] = {}
item_args: List[str] = []
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if item == selected_item:
item_args.append("1")
else:
item_args.append("0")
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if item[0] == selected_item:
item_args.append("1")
else:
item_args.append("0")
result = whiptail(
"--notags", *args, "--radiolist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
if result is None:
return None
return item_map.get(result, result)
def checklist(
text: str,
items: Sequence[ItemType],
selected_items: Sequence[Any],
*args,
) -> Optional[List[Any]]:
assert items, "No items"
item_map: Dict[str, ItemType] = {}
item_args: List[str] = []
for i, item in enumerate(items):
item_id = str(i)
item_args.append(item_id)
if isinstance(item, str):
item_map[item_id] = item
item_args.append(item)
if item in selected_items:
item_args.append("1")
else:
item_args.append("0")
else:
item_map[item_id] = item[0]
item_args.append(item[1])
if item[0] in selected_items:
item_args.append("1")
else:
item_args.append("0")
result = whiptail(
"--notags", *args, "--checklist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
)
if result is None:
return None
return [
item_map.get(result_item, result_item) for result_item in shlex.split(result)
]
def yesno(text: str) -> bool:
return whiptail("--yesno", text, HEIGHT, WIDTH) is not None
def msgbox(text: str) -> None:
whiptail("--msgbox", text, HEIGHT, WIDTH)
def gauge(text: str, seconds: int, parts: int = 20) -> None:
proc = subprocess.Popen(
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert proc.stdin is not None
percent = 0
while percent <= 100:
time.sleep(seconds / parts)
percent += int(100 / parts)
print(percent, file=proc.stdin, flush=True)
proc.communicate()
def error(reason: str) -> None:
msgbox(
f"An error occurred while {reason}.\n" "See local/installer.log for details."
)
# -----------------------------------------------------------------------------
def run_with_gauge(
text: str, commands: Sequence[Sequence[str]], sudo_password: Optional[str] = None
) -> bool:
proc = subprocess.Popen(
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert proc.stdin is not None
percent = 0
seconds = 5
parts = 20
with ThreadPoolExecutor() as executor:
for command in commands:
future = executor.submit(_run_command, command, sudo_password)
while not future.done():
time.sleep(seconds / parts)
percent += int(100 / parts)
if percent > 100:
percent = 0
print(percent, file=proc.stdin, flush=True)
if not future.result():
# Error occurred
return False
proc.communicate()
return True
def _run_command(command: Sequence[str], sudo_password: Optional[str] = None) -> bool:
try:
assert command
proc_input: Optional[str] = None
if (command[0] == "sudo") and (sudo_password is not None):
proc_input = sudo_password
proc = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
_stdout, stderr = proc.communicate(proc_input)
if proc.returncode != 0:
_LOGGER.error("Error running command: %s", command)
_LOGGER.error(stderr)
return False
except Exception:
_LOGGER.exception("Error running command: %s", command)
return False
return True
+1 -1
View File
@@ -1,3 +1,3 @@
wyoming==1.4.1
wyoming==1.5.3
zeroconf==0.88.0
pyring-buffer==1.0.0
@@ -1,2 +1 @@
pysilero-vad==1.0.0
webrtc-noise-gain==1.2.3
+2
View File
@@ -3,3 +3,5 @@ flake8==6.0.0
isort==5.11.3
mypy==0.991
pylint==2.15.9
pytest==7.4.3
pytest-asyncio==0.23.3
+2
View File
@@ -0,0 +1,2 @@
gpiozero
spidev
+1
View File
@@ -0,0 +1 @@
pysilero-vad==1.0.0
+1 -1
View File
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
docker buildx build . \
docker build . \
-t 'rhasspy/wyoming-satellite' "$@"
+7 -2
View File
@@ -7,7 +7,12 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_EXAMPLES_DIR = _PROGRAM_DIR / "examples"
_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
+9 -5
View File
@@ -7,10 +7,14 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR), "--check"])
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR), "--check"])
subprocess.check_call([context.env_exe, "-m", "flake8", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "pylint", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "mypy", str(_MODULE_DIR)])
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "2mic_service.py")]
+ sys.argv[1:]
)
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "4mic_service.py")]
+ sys.argv[1:]
)
+12
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import argparse
import subprocess
import venv
from pathlib import Path
@@ -7,6 +8,9 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
parser = argparse.ArgumentParser()
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
args = parser.parse_args()
# Create virtual environment
builder = venv.EnvBuilder(with_pip=True)
@@ -23,9 +27,17 @@ subprocess.check_call(
pip
+ [
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
"-r",
str(_PROGRAM_DIR / "requirements.txt"),
]
)
if args.dev:
# Install dev requirements
subprocess.check_call(
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")]
)
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import subprocess
import sys
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_TEST_DIR = _PROGRAM_DIR / "tests"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "pytest", _TEST_DIR] + sys.argv[1:])
+32 -13
View File
@@ -1,44 +1,63 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import List
import setuptools
from setuptools import setup
this_dir = Path(__file__).parent
module_dir = this_dir / "wyoming_satellite"
version_path = module_dir / "VERSION"
version = version_path.read_text(encoding="utf-8").strip()
requirements = []
requirements_path = this_dir / "requirements.txt"
if requirements_path.is_file():
with open(requirements_path, "r", encoding="utf-8") as requirements_file:
requirements = requirements_file.read().splitlines()
def get_requirements(req_path: Path) -> List[str]:
if not req_path.is_file():
return []
requirements: List[str] = []
with open(req_path, "r", encoding="utf-8") as req_file:
for line in req_file:
line = line.strip()
if not line:
continue
requirements.append(line)
return requirements
install_requires = get_requirements(this_dir / "requirements.txt")
extras_require = {
"silerovad": get_requirements(this_dir / "requirements_vad.txt"),
"webrtc": get_requirements(this_dir / "requirements_audio_enhancement.txt"),
}
# -----------------------------------------------------------------------------
setup(
name="wyoming_satellite",
version="1.0.0",
version="1.1.0",
description="Wyoming server for remote voice satellite",
url="http://github.com/rhasspy/wyoming-satellite",
author="Michael Hansen",
author_email="mike@rhasspy.org",
packages=setuptools.find_packages(),
install_requires=requirements,
package_data={
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
},
install_requires=install_requires,
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
keywords="rhasspy wyoming satellite",
extras_require={
"silerovad": ["pysilero-vad==1.0.0"],
"webrtc": ["webrtc-noise-gain==1.2.3"],
},
extras_require=extras_require,
entry_points={
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
},
+1
View File
@@ -0,0 +1 @@
"""Wyoming satellite tests."""
+64
View File
@@ -0,0 +1,64 @@
"""Shared code for Wyoming satellite tests."""
import asyncio
import io
from collections.abc import Iterable
from typing import Optional
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event
AUDIO_START = AudioStart(rate=16000, width=2, channels=1)
AUDIO_STOP = AudioStop()
AUDIO_CHUNK = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
)
class FakeStreamReaderWriter:
def __init__(self) -> None:
self._undrained_data = bytes()
self._value = bytes()
self._data_ready = asyncio.Event()
def write(self, data: bytes) -> None:
self._undrained_data += data
def writelines(self, data: Iterable[bytes]) -> None:
for line in data:
self.write(line)
async def drain(self) -> None:
self._value += self._undrained_data
self._undrained_data = bytes()
self._data_ready.set()
self._data_ready.clear()
async def readline(self) -> bytes:
while b"\n" not in self._value:
await self._data_ready.wait()
with io.BytesIO(self._value) as value_io:
data = value_io.readline()
self._value = self._value[len(data) :]
return data
async def readexactly(self, n: int) -> bytes:
while len(self._value) < n:
await self._data_ready.wait()
data = self._value[:n]
self._value = self._value[n:]
return data
class MicClient(AsyncClient):
async def read_event(self) -> Optional[Event]:
# Send 30ms of audio every 30ms
await asyncio.sleep(AUDIO_CHUNK.seconds)
return AUDIO_CHUNK.event()
async def write_event(self, event: Event) -> None:
# Output only
pass
+222
View File
@@ -0,0 +1,222 @@
import asyncio
import logging
from typing import Final, Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event, async_read_event
from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.tts import Synthesize
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import (
AUDIO_CHUNK,
AUDIO_START,
AUDIO_STOP,
FakeStreamReaderWriter,
MicClient,
)
_LOGGER = logging.getLogger()
TIMEOUT: Final = 1
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._event_ready = asyncio.Event()
self._event: Optional[Event] = None
self._detected: bool = False
async def read_event(self) -> Optional[Event]:
await self._event_ready.wait()
self._event_ready.clear()
return self._event
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
if not self._detected:
self._detected = True
self._event = Detection().event()
self._event_ready.set()
class SndClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
pass
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.detection = asyncio.Event()
self.streaming_started = asyncio.Event()
self.streaming_stopped = asyncio.Event()
self.transcript = asyncio.Event()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.detection.set()
elif StreamingStarted.is_type(event.type):
self.streaming_started.set()
elif StreamingStopped.is_type(event.type):
self.streaming_stopped.set()
elif Transcript.is_type(event.type):
self.transcript.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_satellite() -> None:
mic_client = MicClient()
snd_client = SndClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
return_value=snd_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
snd=SndSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
async def event_from_satellite() -> Optional[Event]:
return await async_read_event(server_io)
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
# Fake server connection
server_io = FakeStreamReaderWriter()
await satellite.set_server("test", server_io) # type: ignore
# Start satellite
await satellite.event_from_server(RunSatellite().event())
# Trigger detection
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert Detection.is_type(event.type), event
# Pipeline should start
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert RunPipeline.is_type(event.type), event
run_pipeline = RunPipeline.from_event(event)
assert run_pipeline.start_stage == PipelineStage.ASR
assert run_pipeline.end_stage == PipelineStage.TTS
# Event service should have received detection
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
# Server should be receiving audio now
assert satellite.is_streaming, "Not streaming"
for _ in range(5):
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming start
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
# Send transcript
await satellite.event_from_server(Transcript(text="test").event())
# Event service should have received transcript
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
# Wait for streaming to stop
while satellite.is_streaming:
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming stop
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
# Fake a TTS response
await satellite.event_from_server(Synthesize(text="test").event())
# Event service should have received synthesize
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
# Audio start, chunk, stop
await satellite.event_from_server(AUDIO_START.event())
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
# Event service does not get audio chunks, just start/stop
await satellite.event_from_server(AUDIO_CHUNK.event())
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
await satellite.event_from_server(AUDIO_STOP.event())
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
# Stop satellite
await satellite.stop()
await satellite_task
+97
View File
@@ -0,0 +1,97 @@
import asyncio
import logging
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk
from wyoming.client import AsyncClient
from wyoming.event import Event
from wyoming.satellite import RunSatellite
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import MicClient
_LOGGER = logging.getLogger()
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._detection_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
await self._detection_event.wait()
return Detection().event()
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self._detection_event.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.wake_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.wake_event.set()
@pytest.mark.asyncio
async def test_multiple_wakeups(tmp_path: Path) -> None:
mic_client = MicClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
# Fake server connection
satellite.server_id = "test"
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
await satellite.event_from_server(RunSatellite().event())
await asyncio.wait_for(event_client.wake_event.wait(), timeout=1)
event_client.wake_event.clear()
# Stop streaming
await satellite.event_from_server(Transcript("test").event())
# Should not trigger again within refractory period (default: 5 sec)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(event_client.wake_event.wait(), timeout=0.15)
await satellite.stop()
await satellite_task
+14
View File
@@ -0,0 +1,14 @@
[tox]
env_list =
py{39,310,311}
minversion = 4.12.1
[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
pytest>=7,<8
pytest-asyncio<1
commands =
pytest {tty:--color=yes} {posargs}
+1
View File
@@ -0,0 +1 @@
1.2.0
+34
View File
@@ -0,0 +1,34 @@
"""Voice satellite using the Wyoming protocol."""
from pathlib import Path
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
VadSettings,
WakeSettings,
)
_DIR = Path(__file__).parent
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
__all__ = [
"__version__",
"AlwaysStreamingSatellite",
"EventSettings",
"MicSettings",
"SatelliteBase",
"SatelliteSettings",
"SndSettings",
"VadSettings",
"VadStreamingSatellite",
"WakeStreamingSatellite",
"WakeSettings",
]
+105 -106
View File
@@ -1,19 +1,16 @@
#!/usr/bin/env python3
"""Main entry point for Wyoming satellite."""
import argparse
import asyncio
import logging
import shlex
import sys
import time
import uuid
from functools import partial
from pathlib import Path
from typing import List, Optional
from wyoming.event import Event
from wyoming.info import Attribution, Describe, Info, Satellite
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
from wyoming.info import Attribution, Info, Satellite
from wyoming.server import AsyncServer, AsyncTcpServer
from . import __version__
from .event_handler import SatelliteEventHandler
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
@@ -27,8 +24,15 @@ from .settings import (
SndSettings,
VadSettings,
WakeSettings,
WakeWordAndPipeline,
)
from .utils import (
get_mac_address,
needs_silero,
needs_webrtc,
run_event_command,
split_command,
)
from .utils import run_event_command
_LOGGER = logging.getLogger()
_DIR = Path(__file__).parent
@@ -70,6 +74,22 @@ async def main() -> None:
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
)
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument(
"--mic-seconds-to-mute-after-awake-wav",
type=float,
default=0.5,
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
)
parser.add_argument(
"--mic-no-mute-during-awake-wav",
action="store_true",
help="Don't mute the microphone while awake wav is being played",
)
parser.add_argument(
"--mic-channel-index",
type=int,
help="Take microphone input from a specific channel (first channel is 0)",
)
# Sound output
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
@@ -100,7 +120,9 @@ async def main() -> None:
"--wake-word-name",
action="append",
default=[],
help="Name of wake word to listen for (requires --wake-uri)",
nargs="+",
metavar=("name", "pipeline"),
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
)
parser.add_argument("--wake-command", help="Program to run for wake word detection")
parser.add_argument(
@@ -121,6 +143,12 @@ async def main() -> None:
default=1,
help="Sample channels of wake-command (default: 1)",
)
parser.add_argument(
"--wake-refractory-seconds",
type=float,
default=5.0,
help="Seconds after a wake word detection before another detection is handled (default: 5)",
)
# Voice activity detector
parser.add_argument(
@@ -173,6 +201,10 @@ async def main() -> None:
"--tts-stop-command",
help="Command to run when text to speech response stops",
)
parser.add_argument(
"--tts-played-command",
help="Command to run when text-to-speech audio stopped playing",
)
parser.add_argument(
"--streaming-start-command",
help="Command to run when audio streaming starts",
@@ -185,6 +217,14 @@ async def main() -> None:
"--error-command",
help="Command to run when an error occurs",
)
parser.add_argument(
"--connected-command",
help="Command to run when connected to the server",
)
parser.add_argument(
"--disconnected-command",
help="Command to run when disconnected from the server",
)
# Sounds
parser.add_argument(
@@ -214,7 +254,19 @@ async def main() -> None:
help="Host address for zeroconf discovery (default: detect)",
)
#
parser.add_argument(
"--debug-recording-dir", help="Directory to store audio for debugging"
)
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
parser.add_argument(
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
)
parser.add_argument(
"--version",
action="version",
version=__version__,
help="Print version and exit",
)
args = parser.parse_args()
# Validate args
@@ -247,9 +299,15 @@ async def main() -> None:
if args.vad and (args.wake_uri or args.wake_command):
_LOGGER.warning("VAD is not used with local wake word detection")
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
)
_LOGGER.debug(args)
if args.debug_recording_dir:
args.debug_recording_dir = Path(args.debug_recording_dir)
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
wyoming_info = Info(
satellite=Satellite(
name=args.name,
@@ -257,13 +315,14 @@ async def main() -> None:
description=args.name,
attribution=Attribution(name="", url=""),
installed=True,
version=__version__,
)
)
settings = SatelliteSettings(
mic=MicSettings(
uri=args.mic_uri,
command=_split(args.mic_command),
command=split_command(args.mic_command),
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
@@ -271,6 +330,9 @@ async def main() -> None:
volume_multiplier=args.mic_volume_multiplier,
auto_gain=args.mic_auto_gain,
noise_suppression=args.mic_noise_suppression,
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
channel_index=args.mic_channel_index,
),
vad=VadSettings(
enabled=args.vad,
@@ -281,12 +343,17 @@ async def main() -> None:
),
wake=WakeSettings(
uri=args.wake_uri,
command=_split(args.wake_command),
names=args.wake_word_name,
command=split_command(args.wake_command),
names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
],
refractory_seconds=args.wake_refractory_seconds
if args.wake_refractory_seconds > 0
else None,
),
snd=SndSettings(
uri=args.snd_uri,
command=_split(args.snd_command),
command=split_command(args.snd_command),
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
@@ -296,19 +363,23 @@ async def main() -> None:
),
event=EventSettings(
uri=args.event_uri,
startup=_split(args.startup_command),
streaming_start=_split(args.streaming_start_command),
streaming_stop=_split(args.streaming_stop_command),
detect=_split(args.detect_command),
detection=_split(args.detection_command),
transcript=_split(args.transcript_command),
stt_start=_split(args.stt_start_command),
stt_stop=_split(args.stt_stop_command),
synthesize=_split(args.synthesize_command),
tts_start=_split(args.tts_start_command),
tts_stop=_split(args.tts_stop_command),
error=_split(args.error_command),
startup=split_command(args.startup_command),
streaming_start=split_command(args.streaming_start_command),
streaming_stop=split_command(args.streaming_stop_command),
detect=split_command(args.detect_command),
detection=split_command(args.detection_command),
played=split_command(args.tts_played_command),
transcript=split_command(args.transcript_command),
stt_start=split_command(args.stt_start_command),
stt_stop=split_command(args.stt_stop_command),
synthesize=split_command(args.synthesize_command),
tts_start=split_command(args.tts_start_command),
tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command),
connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command),
),
debug_recording_dir=args.debug_recording_dir,
)
satellite: SatelliteBase
@@ -324,7 +395,7 @@ async def main() -> None:
satellite = AlwaysStreamingSatellite(settings)
if args.startup_command:
await run_event_command(_split(args.startup_command))
await run_event_command(split_command(args.startup_command))
_LOGGER.info("Ready")
@@ -349,7 +420,7 @@ async def main() -> None:
args.zeroconf_host,
)
satellite_task = asyncio.create_task(satellite.run())
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
try:
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
@@ -363,84 +434,12 @@ async def main() -> None:
# -----------------------------------------------------------------------------
class SatelliteEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
wyoming_info: Info,
satellite: SatelliteBase,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.wyoming_info_event = wyoming_info.event()
self.client_id = str(time.monotonic_ns())
self.satellite = satellite
# -------------------------------------------------------------------------
async def handle_event(self, event: Event) -> bool:
"""Handle events from the server."""
if Describe.is_type(event.type):
await self.write_event(self.wyoming_info_event)
return True
if self.satellite.server_id is None:
# Take over after a problem occurred
self.satellite.set_server(self.client_id, self.writer)
elif self.satellite.server_id != self.client_id:
# New connection
_LOGGER.debug("Connection cancelled: %s", self.client_id)
return False
await self.satellite.event_from_server(event)
return True
async def disconnect(self) -> None:
"""Server disconnect."""
if self.satellite.server_id == self.client_id:
self.satellite.clear_server()
# -----------------------------------------------------------------------------
def get_mac_address() -> str:
"""Return MAC address formatted as hex with no colons."""
return "".join(
# pylint: disable=consider-using-f-string
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
::-1
]
)
def needs_webrtc(args: argparse.Namespace) -> bool:
"""Return True if webrtc must be used."""
return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
def needs_silero(args: argparse.Namespace) -> bool:
"""Return True if silero-vad must be used."""
return args.vad
def _split(command: Optional[str]) -> Optional[List[str]]:
if not command:
return None
return shlex.split(command)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
def run():
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
run()
+57
View File
@@ -0,0 +1,57 @@
"""Wyoming event handler for satellites."""
import argparse
import logging
import time
from wyoming.event import Event
from wyoming.info import Describe, Info
from wyoming.server import AsyncEventHandler
from .satellite import SatelliteBase
_LOGGER = logging.getLogger()
class SatelliteEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
wyoming_info: Info,
satellite: SatelliteBase,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.wyoming_info = wyoming_info
self.client_id = str(time.monotonic_ns())
self.satellite = satellite
# -------------------------------------------------------------------------
async def handle_event(self, event: Event) -> bool:
"""Handle events from the server."""
if Describe.is_type(event.type):
await self.satellite.update_info(self.wyoming_info)
await self.write_event(self.wyoming_info.event())
return True
if self.satellite.server_id is None:
# Take over after a problem occurred
await self.satellite.set_server(self.client_id, self.writer)
elif self.satellite.server_id != self.client_id:
# New connection
_LOGGER.debug("Connection cancelled: %s", self.client_id)
return False
await self.satellite.event_from_server(event)
return True
async def disconnect(self) -> None:
"""Server disconnect."""
if self.satellite.server_id == self.client_id:
await self.satellite.clear_server()
+453 -33
View File
@@ -1,33 +1,54 @@
"""Satellite code."""
import array
import asyncio
import logging
import math
import time
import wave
from enum import Enum, auto
from pathlib import Path
from typing import Callable, Optional, Set, Union
from typing import Callable, Dict, Final, List, Optional, Set, Union
from pyring_buffer import RingBuffer
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.error import Error
from wyoming.event import Event, async_write_event
from wyoming.info import Describe, Info
from wyoming.mic import MicProcessAsyncClient
from wyoming.ping import Ping, Pong
from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.snd import SndProcessAsyncClient
from wyoming.satellite import (
PauseSatellite,
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.snd import Played, SndProcessAsyncClient
from wyoming.tts import Synthesize
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
from .settings import SatelliteSettings
from .utils import multiply_volume, run_event_command, wav_to_events
from .utils import (
DebugAudioWriter,
multiply_volume,
normalize_wake_word,
run_event_command,
wav_to_events,
)
from .vad import SileroVad
from .webrtc import WebRtcAudio
_LOGGER = logging.getLogger()
_PONG_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2
_WAKE_INFO_TIMEOUT: Final = 2
class State(Enum):
NOT_STARTED = auto()
@@ -60,10 +81,30 @@ class SatelliteBase:
self._event_task: Optional[asyncio.Task] = None
self._event_queue: "Optional[asyncio.Queue[Event]]" = None
self._ping_server_enabled: bool = False
self._pong_received_event = asyncio.Event()
self._ping_server_task: Optional[asyncio.Task] = None
self.microphone_muted = False
self._unmute_microphone_task: Optional[asyncio.Task] = None
# Debug audio recording
self.wake_audio_writer: Optional[DebugAudioWriter] = None
self.stt_audio_writer: Optional[DebugAudioWriter] = None
if settings.debug_recording_dir:
self.wake_audio_writer = DebugAudioWriter(
settings.debug_recording_dir,
"wake",
ring_buffer_size=(2 * 16000 * 2 * 1), # last 2 sec
)
self.stt_audio_writer = DebugAudioWriter(
settings.debug_recording_dir, "stt"
)
@property
def is_running(self) -> bool:
"""True if not stopping/stopped."""
return self._state not in (State.STOPPING, State.STOPPED)
return self._state not in (State.STOPPED,)
@property
def state(self) -> State:
@@ -107,17 +148,21 @@ class SatelliteBase:
while self.state != State.STOPPED:
await self._state_changed.wait()
def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None:
async def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None:
"""Set event writer."""
self.server_id = server_id
self._writer = writer
_LOGGER.debug("Server set: %s", server_id)
await self.trigger_server_connected()
def clear_server(self) -> None:
async def clear_server(self) -> None:
"""Remove writer."""
self.server_id = None
self._writer = None
self._disable_ping()
_LOGGER.debug("Server disconnected")
await self.trigger_server_disonnected()
async def event_to_server(self, event: Event) -> None:
"""Send an event to the server."""
@@ -127,13 +172,50 @@ class SatelliteBase:
try:
await async_write_event(event, self._writer)
except Exception as err:
self.clear_server()
await self.clear_server()
if isinstance(err, ConnectionResetError):
_LOGGER.warning("Server disconnected unexpectedly")
else:
_LOGGER.exception("Unexpected error sending event to server")
def _enable_ping(self) -> None:
self._ping_server_enabled = True
self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping")
def _disable_ping(self) -> None:
self._ping_server_enabled = False
if self._ping_server_task is not None:
self._ping_server_task.cancel()
self._ping_server_task = None
async def _ping_server(self) -> None:
try:
while self.is_running:
await asyncio.sleep(_PING_SEND_DELAY)
if (self.server_id is None) or (not self._ping_server_enabled):
# No server connected
continue
# Send ping and wait for pong
self._pong_received_event.clear()
await self.event_to_server(Ping().event())
try:
await asyncio.wait_for(
self._pong_received_event.wait(), timeout=_PONG_TIMEOUT
)
except asyncio.TimeoutError:
if self.server_id is None:
# No server connected
continue
_LOGGER.warning("Did not receive ping response within timeout")
await self.clear_server()
except asyncio.CancelledError:
pass
except Exception:
_LOGGER.exception("Unexpected error in ping server task")
# -------------------------------------------------------------------------
async def _start(self) -> None:
@@ -152,6 +234,7 @@ class SatelliteBase:
self._writer = None
await self._disconnect_from_services()
self._disable_ping()
self.state = State.STOPPED
async def stopped(self) -> None:
@@ -159,7 +242,19 @@ class SatelliteBase:
async def event_from_server(self, event: Event) -> None:
"""Called when an event is received from the server."""
if AudioChunk.is_type(event.type):
if Ping.is_type(event.type):
# Respond with pong
ping = Ping.from_event(event)
await self.event_to_server(Pong(text=ping.text).event())
if not self._ping_server_enabled:
# Enable pinging
self._enable_ping()
_LOGGER.debug("Ping enabled")
elif Pong.is_type(event.type):
# Response from our ping
self._pong_received_event.set()
elif AudioChunk.is_type(event.type):
# TTS audio
await self.event_to_snd(event)
elif AudioStart.is_type(event.type):
@@ -199,14 +294,16 @@ class SatelliteBase:
if not AudioChunk.is_type(event.type):
await self.forward_event(event)
async def _send_run_pipeline(self) -> None:
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
"""Sends a RunPipeline event with the correct stages."""
if self.settings.wake.enabled:
# Local wake word detection
start_stage = PipelineStage.ASR
restart_on_end = False
else:
# Remote wake word detection
start_stage = PipelineStage.WAKE
restart_on_end = not self.settings.vad.enabled
if self.settings.snd.enabled:
# Play TTS response
@@ -215,7 +312,18 @@ class SatelliteBase:
# No audio output
end_stage = PipelineStage.HANDLE
run_pipeline = RunPipeline(start_stage=start_stage, end_stage=end_stage).event()
run_pipeline = RunPipeline(
start_stage=start_stage,
end_stage=end_stage,
name=pipeline_name,
restart_on_end=restart_on_end,
snd_format=AudioFormat(
rate=self.settings.snd.rate,
width=self.settings.snd.width,
channels=self.settings.snd.channels,
),
).event()
_LOGGER.debug(run_pipeline)
await self.event_to_server(run_pipeline)
await self.forward_event(run_pipeline)
@@ -235,28 +343,30 @@ class SatelliteBase:
"Connecting to mic service: %s",
self.settings.mic.uri or self.settings.mic.command,
)
self._mic_task = asyncio.create_task(self._mic_task_proc())
self._mic_task = asyncio.create_task(self._mic_task_proc(), name="mic")
if self.settings.snd.enabled:
_LOGGER.debug(
"Connecting to snd service: %s",
self.settings.snd.uri or self.settings.snd.command,
)
self._snd_task = asyncio.create_task(self._snd_task_proc())
self._snd_task = asyncio.create_task(self._snd_task_proc(), name="snd")
if self.settings.wake.enabled:
_LOGGER.debug(
"Connecting to wake service: %s",
self.settings.wake.uri or self.settings.wake.command,
)
self._wake_task = asyncio.create_task(self._wake_task_proc())
self._wake_task = asyncio.create_task(self._wake_task_proc(), name="wake")
if self.settings.event.enabled:
_LOGGER.debug(
"Connecting to event service: %s",
self.settings.event.uri or self.settings.event.command,
)
self._event_task = asyncio.create_task(self._event_task_proc())
self._event_task = asyncio.create_task(
self._event_task_proc(), name="event"
)
_LOGGER.info("Connected to services")
@@ -351,15 +461,36 @@ class SatelliteBase:
continue
# Audio processing
if self.settings.mic.needs_processing and AudioChunk.is_type(
event.type
if (
self.settings.mic.needs_processing
or (self.settings.mic.channel_index is not None)
and AudioChunk.is_type(event.type)
):
chunk = AudioChunk.from_event(event)
if self.settings.mic.channel_index is not None:
if chunk.width != 2:
raise ValueError(
"Mic channel index selection requires 16-bit samples"
)
# Convert to unsigned 16-bit array to make channel extraction easier
audio_array = array.array("H", chunk.audio)
audio_bytes = audio_array[
self.settings.mic.channel_index :: chunk.channels
].tobytes()
if self.settings.mic.needs_processing:
if audio_bytes is None:
audio_bytes = chunk.audio
audio_bytes = self._process_mic_audio(chunk.audio)
event = AudioChunk(
rate=chunk.rate,
width=chunk.width,
channels=chunk.channels,
channels=chunk.channels
if (self.settings.mic.channel_index is None)
else 1,
audio=audio_bytes,
).event()
else:
@@ -458,6 +589,7 @@ class SatelliteBase:
event.type
):
await _disconnect()
await self.trigger_played()
snd_client = None # reconnect on next event
except asyncio.CancelledError:
break
@@ -479,17 +611,41 @@ class SatelliteBase:
return audio_bytes
async def _play_wav(self, wav_path: Optional[Union[str, Path]]) -> None:
async def _play_wav(
self, wav_path: Optional[Union[str, Path]], mute_microphone: bool = False
) -> None:
"""Send WAV as events to sound service."""
if (not wav_path) or (not self.settings.snd.enabled):
return
try:
if mute_microphone:
with wave.open(str(wav_path), "rb") as wav_file:
seconds_to_mute = wav_file.getnframes() / wav_file.getframerate()
seconds_to_mute += self.settings.mic.seconds_to_mute_after_awake_wav
_LOGGER.debug("Muting microphone for %s second(s)", seconds_to_mute)
self.microphone_muted = True
self._unmute_microphone_task = asyncio.create_task(
self._unmute_microphone_after(seconds_to_mute)
)
for event in wav_to_events(
wav_path,
samples_per_chunk=self.settings.snd.samples_per_chunk,
volume_multiplier=self.settings.snd.volume_multiplier,
):
await self.event_to_snd(event)
except Exception:
# Unmute in case of an error
self.microphone_muted = False
raise
async def _unmute_microphone_after(self, seconds: float) -> None:
await asyncio.sleep(seconds)
self.microphone_muted = False
_LOGGER.debug("Unmuted microphone")
# -------------------------------------------------------------------------
# Wake
@@ -528,9 +684,19 @@ class SatelliteBase:
pending: Set[asyncio.Task] = set()
async def _disconnect() -> None:
nonlocal to_client_task, from_client_task
try:
if wake_client is not None:
await wake_client.disconnect()
# Clean up tasks
if to_client_task is not None:
to_client_task.cancel()
to_client_task = None
if from_client_task is not None:
from_client_task.cancel()
from_client_task = None
except Exception:
pass # ignore disconnect errors
@@ -557,12 +723,16 @@ class SatelliteBase:
# Read/write in "parallel"
if to_client_task is None:
# From satellite to wake service
to_client_task = asyncio.create_task(self._wake_queue.get())
to_client_task = asyncio.create_task(
self._wake_queue.get(), name="wake_to_client"
)
pending.add(to_client_task)
if from_client_task is None:
# From wake service to satellite
from_client_task = asyncio.create_task(wake_client.read_event())
from_client_task = asyncio.create_task(
wake_client.read_event(), name="wake_from_client"
)
pending.add(from_client_task)
done, pending = await asyncio.wait(
@@ -603,13 +773,29 @@ class SatelliteBase:
async def _send_wake_detect(self) -> None:
"""Inform wake word service of which wake words to detect."""
await self.event_to_wake(Detect(names=self.settings.wake.names).event())
wake_names: Optional[List[str]] = None
if self.settings.wake.names:
wake_names = [w.name for w in self.settings.wake.names]
await self.event_to_wake(Detect(names=wake_names).event())
await self.trigger_detect()
# -------------------------------------------------------------------------
# Events
# -------------------------------------------------------------------------
async def trigger_server_connected(self) -> None:
"""Called when connected to server."""
_LOGGER.info("Connected to server")
await run_event_command(self.settings.event.connected)
await self.forward_event(SatelliteConnected().event())
async def trigger_server_disonnected(self) -> None:
"""Called when disconnected from server."""
_LOGGER.info("Disconnected from server")
await run_event_command(self.settings.event.disconnected)
await self.forward_event(SatelliteDisconnected().event())
async def trigger_streaming_start(self) -> None:
"""Called when audio streaming starts."""
await run_event_command(self.settings.event.streaming_start)
@@ -627,7 +813,15 @@ class SatelliteBase:
async def trigger_detection(self, detection: Detection) -> None:
"""Called when wake word is detected."""
await run_event_command(self.settings.event.detection, detection.name)
await self._play_wav(self.settings.snd.awake_wav)
await self._play_wav(
self.settings.snd.awake_wav,
mute_microphone=self.settings.mic.mute_during_awake_wav,
)
async def trigger_played(self) -> None:
"""Called when audio stopped playing"""
await run_event_command(self.settings.event.played)
await self.forward_event(Played().event())
async def trigger_transcript(self, transcript: Transcript) -> None:
"""Called when speech-to-text text is received."""
@@ -706,6 +900,13 @@ class SatelliteBase:
await _disconnect()
# -------------------------------------------------------------------------
# Info
# -------------------------------------------------------------------------
async def update_info(self, info: Info) -> None:
pass
# -----------------------------------------------------------------------------
@@ -717,6 +918,12 @@ class AlwaysStreamingSatellite(SatelliteBase):
super().__init__(settings)
self.is_streaming = False
if settings.vad.enabled:
_LOGGER.warning("VAD is enabled but will not be used")
if settings.wake.enabled:
_LOGGER.warning("Local wake word detection is enabled but will not be used")
async def event_from_server(self, event: Event) -> None:
await super().event_from_server(event)
@@ -725,7 +932,20 @@ class AlwaysStreamingSatellite(SatelliteBase):
_LOGGER.info("Streaming audio")
await self._send_run_pipeline()
await self.trigger_streaming_start()
elif Transcript.is_type(event.type):
elif PauseSatellite.is_type(event.type):
self.is_streaming = False
_LOGGER.info("Satellite paused")
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
elif Transcript.is_type(event.type) or Error.is_type(event.type):
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
if Transcript.is_type(event.type):
# We're always streaming
_LOGGER.info("Streaming audio")
# Re-trigger streaming start even though we technically don't stop
@@ -735,13 +955,21 @@ class AlwaysStreamingSatellite(SatelliteBase):
async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None
) -> None:
if not self.is_streaming:
if (not self.is_streaming) or self.microphone_muted:
return
if AudioChunk.is_type(event.type):
# Forward to server
await self.event_to_server(event)
# Debug audio recording
if self.stt_audio_writer is not None:
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self.stt_audio_writer.write(audio_bytes)
# -----------------------------------------------------------------------------
@@ -771,18 +999,58 @@ class VadStreamingSatellite(SatelliteBase):
vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2))
self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes)
if settings.wake.enabled:
_LOGGER.warning("Local wake word detection is enabled but will not be used")
self._is_paused = False
async def event_from_server(self, event: Event) -> None:
await super().event_from_server(event)
if RunSatellite.is_type(event.type):
self._is_paused = False
_LOGGER.info("Waiting for speech")
elif Detection.is_type(event.type):
# Start debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.start()
elif (
Transcript.is_type(event.type)
or Error.is_type(event.type)
or PauseSatellite.is_type(event.type)
):
if PauseSatellite.is_type(event.type):
self._is_paused = True
_LOGGER.debug("Satellite paused")
self.is_streaming = False
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None
) -> None:
if not AudioChunk.is_type(event.type):
if (
(not AudioChunk.is_type(event.type))
or self.microphone_muted
or self._is_paused
):
return
# Only unpack chunk once
chunk: Optional[AudioChunk] = None
# Debug audio recording
if self.stt_audio_writer is not None:
if audio_bytes is None:
# Need to unpack
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
self.stt_audio_writer.write(audio_bytes)
if (
self.is_streaming
and (self.timeout_seconds is not None)
@@ -792,6 +1060,10 @@ class VadStreamingSatellite(SatelliteBase):
self.is_streaming = False
self.timeout_seconds = None
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
# Stop pipeline
await self.event_to_server(AudioStop().event())
@@ -800,10 +1072,11 @@ class VadStreamingSatellite(SatelliteBase):
if not self.is_streaming:
# Check VAD
chunk: Optional[AudioChunk] = None
if audio_bytes is None:
if chunk is None:
# Need to unpack
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
if not self.vad(audio_bytes):
@@ -872,26 +1145,108 @@ class WakeStreamingSatellite(SatelliteBase):
super().__init__(settings)
self.is_streaming = False
# Timestamp in the future when the refractory period is over (set with
# time.monotonic()).
# wake word id -> seconds
self.refractory_timestamp: Dict[Optional[str], float] = {}
if settings.vad.enabled:
_LOGGER.warning("VAD is enabled but will not be used")
# Used for debug audio recording so both wake and stt WAV files have the
# same timestamp.
self._debug_recording_timestamp: Optional[int] = None
self._is_paused = False
self._wake_info: Optional[Info] = None
self._wake_info_ready = asyncio.Event()
async def event_from_server(self, event: Event) -> None:
if Transcript.is_type(event.type):
# Only check event types once
is_run_satellite = False
is_pause_satellite = False
is_transcript = False
is_error = False
if RunSatellite.is_type(event.type):
is_run_satellite = True
self._is_paused = False
elif PauseSatellite.is_type(event.type):
is_pause_satellite = True
elif Transcript.is_type(event.type):
is_transcript = True
elif Error.is_type(event.type):
is_error = True
if is_transcript or is_pause_satellite:
# Stop streaming before event_from_server is called because it will
# play the "done" WAV.
self.is_streaming = False
# Stop debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
await super().event_from_server(event)
if RunSatellite.is_type(event.type) or Transcript.is_type(event.type):
if is_run_satellite or is_transcript or is_error or is_pause_satellite:
# Stop streaming
self.is_streaming = False
if is_pause_satellite:
self._is_paused = True
_LOGGER.debug("Satellite is paused")
else:
# Go back to wake word detection
await self.trigger_streaming_stop()
# It's possible to be paused in the middle of streaming
if not self._is_paused:
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
# Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None:
self.wake_audio_writer.start(
timestamp=self._debug_recording_timestamp
)
async def trigger_server_disonnected(self) -> None:
await super().trigger_server_disonnected()
self.is_streaming = False
# Stop debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
await self.trigger_streaming_stop()
async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None
) -> None:
if not AudioChunk.is_type(event.type):
if (
(not AudioChunk.is_type(event.type))
or self.microphone_muted
or self._is_paused
):
return
# Debug audio recording
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
if self.wake_audio_writer is not None:
self.wake_audio_writer.write(audio_bytes)
if self.stt_audio_writer is not None:
self.stt_audio_writer.write(audio_bytes)
if self.is_streaming:
# Forward to server
await self.event_to_server(event)
@@ -900,13 +1255,78 @@ class WakeStreamingSatellite(SatelliteBase):
await self.event_to_wake(event)
async def event_from_wake(self, event: Event) -> None:
if self.is_streaming:
if Info.is_type(event.type):
self._wake_info = Info.from_event(event)
self._wake_info_ready.set()
return
if self.is_streaming or (self.server_id is None):
# Not detecting or no server connected
return
if Detection.is_type(event.type):
detection = Detection.from_event(event)
# Check refractory period to avoid multiple back-to-back detections
refractory_timestamp = self.refractory_timestamp.get(detection.name)
if (refractory_timestamp is not None) and (
refractory_timestamp > time.monotonic()
):
_LOGGER.debug("Wake word detection occurred during refractory period")
return
# Stop debug recording (wake)
if self.wake_audio_writer is not None:
self.wake_audio_writer.stop()
# Start debug recording (stt)
if self.stt_audio_writer is not None:
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
_LOGGER.debug(detection)
self.is_streaming = True
_LOGGER.debug("Streaming audio")
await self._send_run_pipeline()
if self.settings.wake.refractory_seconds is not None:
# Another detection may not occur for this wake word until
# refractory period is over.
self.refractory_timestamp[detection.name] = (
time.monotonic() + self.settings.wake.refractory_seconds
)
else:
# No refractory period
self.refractory_timestamp.pop(detection.name, None)
# Forward to the server
await self.event_to_server(event)
# Match detected wake word name with pipeline name
pipeline_name: Optional[str] = None
if self.settings.wake.names:
detection_name = normalize_wake_word(detection.name)
for wake_name in self.settings.wake.names:
if normalize_wake_word(wake_name.name) == detection_name:
pipeline_name = wake_name.pipeline
break
await self._send_run_pipeline(pipeline_name=pipeline_name)
await self.forward_event(event) # forward to event service
await self.trigger_detection(Detection.from_event(event))
await self.trigger_streaming_start()
async def update_info(self, info: Info) -> None:
self._wake_info = None
self._wake_info_ready.clear()
await self.event_to_wake(Describe().event())
try:
await asyncio.wait_for(
self._wake_info_ready.wait(), timeout=_WAKE_INFO_TIMEOUT
)
if self._wake_info is not None:
# Update wake info only
info.wake = self._wake_info.wake
except asyncio.TimeoutError:
_LOGGER.warning("Failed to get info from wake service")
+34 -6
View File
@@ -1,6 +1,7 @@
"""Satellite settings."""
from abc import ABC
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@@ -48,6 +49,15 @@ class MicSettings(ServiceSettings):
samples_per_chunk: int = 1024
"""Samples to read at a time from mic command"""
mute_during_awake_wav: bool = True
"""True if microphone audio should be muted while the awake WAV is playing."""
seconds_to_mute_after_awake_wav: float = 0.5
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
channel_index: Optional[int] = None
"""Index of microphone channel to use. When not set, all input channels are used as-is."""
@property
def needs_webrtc(self) -> bool:
"""True if webrtc audio enhancements are needed."""
@@ -93,11 +103,19 @@ class SndSettings(ServiceSettings):
return self.enabled and (self.volume_multiplier != 1.0)
@dataclass(frozen=True)
class WakeWordAndPipeline:
"""Wake word name + optional pipeline name."""
name: str
pipeline: Optional[str] = None
@dataclass(frozen=True)
class WakeSettings(ServiceSettings):
"""Wake word service settings."""
names: Optional[List[str]] = None
names: Optional[List[WakeWordAndPipeline]] = None
"""List of wake word names to listen for."""
rate: int = 16000
@@ -109,6 +127,9 @@ class WakeSettings(ServiceSettings):
channels: int = 1
"""Sample channels in wake word audio"""
refractory_seconds: Optional[float] = 5.0
"""Seconds after a wake word detection before another detection is handled."""
@dataclass(frozen=True)
class VadSettings:
@@ -139,6 +160,7 @@ class EventSettings(ServiceSettings):
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
played: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
@@ -146,6 +168,8 @@ class EventSettings(ServiceSettings):
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
@dataclass(frozen=True)
@@ -153,8 +177,12 @@ class SatelliteSettings:
"""Wyoming satellite settings."""
mic: MicSettings
vad: VadSettings
wake: WakeSettings
snd: SndSettings
event: EventSettings
vad: VadSettings = field(default_factory=VadSettings)
wake: WakeSettings = field(default_factory=WakeSettings)
snd: SndSettings = field(default_factory=SndSettings)
event: EventSettings = field(default_factory=EventSettings)
restart_timeout: float = 5.0
debug_recording_dir: Optional[Path] = None
"""Path to directory where debug audio is written."""
+30
View File
@@ -0,0 +1,30 @@
"""Utility methods."""
from .audio import (
AudioBuffer,
DebugAudioWriter,
chunk_samples,
multiply_volume,
wav_to_events,
)
from .misc import (
get_mac_address,
needs_silero,
needs_webrtc,
normalize_wake_word,
run_event_command,
split_command,
)
__all__ = [
"AudioBuffer",
"chunk_samples",
"DebugAudioWriter",
"get_mac_address",
"multiply_volume",
"needs_silero",
"needs_webrtc",
"normalize_wake_word",
"run_event_command",
"split_command",
"wav_to_events",
]
@@ -1,11 +1,12 @@
"""Utilities for Wyoming satellite."""
"""Audio utilities."""
import array
import asyncio
import logging
import time
import wave
from pathlib import Path
from typing import Iterable, Iterator, List, Optional, Union
from typing import Iterable, Iterator, Optional, Union
from pyring_buffer import RingBuffer
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event
@@ -98,27 +99,6 @@ def chunk_samples(
leftover_chunk_buffer.append(rest_samples)
async def run_event_command(
command: Optional[List[str]], command_input: Optional[str] = None
) -> None:
"""Run a custom event command with optional input."""
if not command:
return
_LOGGER.debug("Running %s", command)
program, *program_args = command
proc = await asyncio.create_subprocess_exec(
program, *program_args, stdin=asyncio.subprocess.PIPE
)
assert proc.stdin is not None
if command_input:
await proc.communicate(input=command_input.encode("utf-8"))
else:
proc.stdin.close()
await proc.wait()
def wav_to_events(
wav_path: Union[str, Path],
samples_per_chunk: int = 1024,
@@ -152,3 +132,74 @@ def wav_to_events(
audio_bytes = wav_file.readframes(samples_per_chunk)
yield AudioStop(timestamp=timestamp).event()
class DebugAudioWriter:
def __init__(
self,
dir_path: Union[str, Path],
suffix: str,
rate: int = 16000,
width: int = 2,
channels: int = 1,
ring_buffer_size: Optional[int] = None,
) -> None:
self.dir_path = Path(dir_path)
self.suffix = suffix
self.rate = rate
self.width = width
self.channels = channels
self._wav_path: Optional[Path] = None
self._wav_writer: Optional[wave.Wave_write] = None
# If ring buffer size is set, we will hold audio in a ring buffer
# instead of directly writing it to disk.
#
# This allows only the last few seconds of wake word audio to be stored.
self._ring_buffer: Optional[RingBuffer] = None
if ring_buffer_size is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer = RingBuffer(ring_buffer_size)
def start(self, timestamp: Optional[int] = None) -> None:
self.stop()
if timestamp is None:
timestamp = time.monotonic_ns()
self._wav_path = self.dir_path / f"{timestamp}-{self.suffix}.wav"
self._wav_path.parent.mkdir(parents=True, exist_ok=True)
self._wav_writer = wave.open(str(self._wav_path), "wb")
self._wav_writer.setframerate(self.rate)
self._wav_writer.setsampwidth(self.width)
self._wav_writer.setnchannels(self.channels)
_LOGGER.debug("Started recording to %s", self._wav_path)
def write(self, audio: bytes) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer.put(audio)
else:
# Write directly to disk
self._wav_writer.writeframes(audio)
def stop(self) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Write all audio now and reset buffer
self._wav_writer.writeframes(self._ring_buffer.getvalue())
self._ring_buffer = RingBuffer(self._ring_buffer.maxlen)
self._wav_writer.close()
self._wav_writer = None
_LOGGER.debug("Stopped recording to %s", self._wav_path)
self._wav_path = None
+82
View File
@@ -0,0 +1,82 @@
"""Miscellaneous utilities."""
import argparse
import asyncio
import logging
import re
import shlex
import unicodedata
import uuid
from functools import lru_cache
from typing import List, Optional
_LOGGER = logging.getLogger()
async def run_event_command(
command: Optional[List[str]], command_input: Optional[str] = None
) -> None:
"""Run a custom event command with optional input."""
if not command:
return
_LOGGER.debug("Running %s", command)
program, *program_args = command
proc = await asyncio.create_subprocess_exec(
program, *program_args, stdin=asyncio.subprocess.PIPE
)
assert proc.stdin is not None
if command_input:
await proc.communicate(input=command_input.encode("utf-8"))
else:
proc.stdin.close()
await proc.wait()
def get_mac_address() -> str:
"""Return MAC address formatted as hex with no colons."""
return "".join(
# pylint: disable=consider-using-f-string
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
::-1
]
)
def needs_webrtc(args: argparse.Namespace) -> bool:
"""Return True if webrtc must be used."""
return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
def needs_silero(args: argparse.Namespace) -> bool:
"""Return True if silero-vad must be used."""
return args.vad
def split_command(command: Optional[str]) -> Optional[List[str]]:
"""Split command line program/args if not empty."""
if not command:
return None
return shlex.split(command)
@lru_cache
def normalize_wake_word(wake_word: str) -> str:
"""Normalizes a wake word name for comparison."""
# Lower case
wake_word = wake_word.strip().casefold()
# Remove version numbers like v1.0
wake_word = re.sub(r"v[0-9]+(\.[0-9])+", "", wake_word)
# Replace anything besides letters/numbers with whitespace
wake_word = "".join(
c if unicodedata.category(c) in ("Ll", "Nd") else " " for c in wake_word
)
# Normalize whitespace
wake_word = " ".join(wake_word.strip().split())
return wake_word