50 Commits
Author SHA1 Message Date
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
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
44 changed files with 3707 additions and 193 deletions
+1
View File
@@ -6,3 +6,4 @@
!script/run !script/run
!wyoming_satellite/*.py !wyoming_satellite/*.py
!docker/run !docker/run
!sounds/*.wav
+18
View File
@@ -0,0 +1,18 @@
name: test
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
+1
View File
@@ -8,6 +8,7 @@ RUN apt-get update && \
WORKDIR /app WORKDIR /app
COPY sounds/ ./sounds/
COPY script/setup ./script/ COPY script/setup ./script/
COPY setup.py requirements.txt MANIFEST.in ./ COPY setup.py requirements.txt MANIFEST.in ./
COPY wyoming_satellite/ ./wyoming_satellite/ COPY wyoming_satellite/ ./wyoming_satellite/
+35 -3
View File
@@ -1,9 +1,35 @@
# Wyoming Satellite # 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 ## Installation
Install the necessary system dependencies:
``` sh
sudo apt-get install python3-venv python3-pip
```
Then run the install script:
``` sh ``` sh
script/setup script/setup
``` ```
@@ -94,11 +120,15 @@ script/run \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \ --mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -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-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. 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 ## Sounds
You can play a WAV file when the wake word is detected (locally or remotely), and when speech-to-text has completed: 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 --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). 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. 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.
@@ -146,5 +176,7 @@ Satellites can respond to events from the server by running commands:
* `--tts-start-command` - text-to-speech response started (no stdin) * `--tts-start-command` - text-to-speech response started (no stdin)
* `--tts-stop-command` - text-to-speech response stopped (no stdin) * `--tts-stop-command` - text-to-speech response stopped (no stdin)
* `--error-command` - an error was sent from the server (text on 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. 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.4.1'
```
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 import spidev
from wyoming.asr import Transcript from wyoming.asr import Transcript
from wyoming.event import Event 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.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted from wyoming.vad import VoiceStarted
from wyoming.wake import Detection from wyoming.wake import Detection
@@ -107,6 +113,15 @@ class LEDsEventHandler(AsyncEventHandler):
self.color(_BLACK) self.color(_BLACK)
elif RunSatellite.is_type(event.type): elif RunSatellite.is_type(event.type):
self.color(_BLACK) 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 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.0
zeroconf==0.88.0 zeroconf==0.88.0
pyring-buffer==1.0.0 pyring-buffer==1.0.0
@@ -1,2 +1 @@
pysilero-vad==1.0.0
webrtc-noise-gain==1.2.3 webrtc-noise-gain==1.2.3
+2
View File
@@ -3,3 +3,5 @@ flake8==6.0.0
isort==5.11.3 isort==5.11.3
mypy==0.991 mypy==0.991
pylint==2.15.9 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
+7 -2
View File
@@ -7,7 +7,12 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite" _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) 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", "black"] + _FORMAT_DIRS)
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR)]) 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 _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite" _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) 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", "black"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR), "--check"]) subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "flake8", str(_MODULE_DIR)]) subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "pylint", str(_MODULE_DIR)]) subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "mypy", str(_MODULE_DIR)]) 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 #!/usr/bin/env python3
import argparse
import subprocess import subprocess
import venv import venv
from pathlib import Path from pathlib import Path
@@ -7,6 +8,9 @@ _DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _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 # Create virtual environment
builder = venv.EnvBuilder(with_pip=True) builder = venv.EnvBuilder(with_pip=True)
@@ -23,9 +27,17 @@ subprocess.check_call(
pip pip
+ [ + [
"install", "install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f", "-f",
"https://synesthesiam.github.io/prebuilt-apps/", "https://synesthesiam.github.io/prebuilt-apps/",
"-r", "-r",
str(_PROGRAM_DIR / "requirements.txt"), 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:])
+26 -11
View File
@@ -1,29 +1,47 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from pathlib import Path from pathlib import Path
from typing import List
import setuptools import setuptools
from setuptools import setup from setuptools import setup
this_dir = Path(__file__).parent this_dir = Path(__file__).parent
requirements = []
requirements_path = this_dir / "requirements.txt" def get_requirements(req_path: Path) -> List[str]:
if requirements_path.is_file(): if not req_path.is_file():
with open(requirements_path, "r", encoding="utf-8") as requirements_file: return []
requirements = requirements_file.read().splitlines()
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( setup(
name="wyoming_satellite", name="wyoming_satellite",
version="1.0.0", version="1.1.0",
description="Wyoming server for remote voice satellite", description="Wyoming server for remote voice satellite",
url="http://github.com/rhasspy/wyoming-satellite", url="http://github.com/rhasspy/wyoming-satellite",
author="Michael Hansen", author="Michael Hansen",
author_email="mike@rhasspy.org", author_email="mike@rhasspy.org",
packages=setuptools.find_packages(), packages=setuptools.find_packages(),
install_requires=requirements, install_requires=install_requires,
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
@@ -35,10 +53,7 @@ setup(
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
], ],
keywords="rhasspy wyoming satellite", keywords="rhasspy wyoming satellite",
extras_require={ extras_require=extras_require,
"silerovad": ["pysilero-vad==1.0.0"],
"webrtc": ["webrtc-noise-gain==1.2.3"],
},
entry_points={ entry_points={
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"] "console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
}, },
+1
View File
@@ -0,0 +1 @@
"""Wyoming satellite tests."""
+6
View File
@@ -0,0 +1,6 @@
"""Shared code for Wyoming satellite tests."""
from wyoming.audio import AudioChunk
AUDIO_CHUNK = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
)
+106
View File
@@ -0,0 +1,106 @@
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 AUDIO_CHUNK
_LOGGER = logging.getLogger()
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
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:
_LOGGER.error(event.type)
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"),
)
)
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
+28
View File
@@ -0,0 +1,28 @@
"""Voice satellite using the Wyoming protocol."""
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
VadSettings,
WakeSettings,
)
__all__ = [
"AlwaysStreamingSatellite",
"EventSettings",
"MicSettings",
"SatelliteBase",
"SatelliteSettings",
"SndSettings",
"VadSettings",
"VadStreamingSatellite",
"WakeSettings",
"WakeStreamingSatellite",
]
+60 -103
View File
@@ -1,19 +1,15 @@
#!/usr/bin/env python3 """Main entry point for Wyoming satellite."""
import argparse import argparse
import asyncio import asyncio
import logging import logging
import shlex
import sys import sys
import time
import uuid
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import List, Optional
from wyoming.event import Event from wyoming.info import Attribution, Info, Satellite
from wyoming.info import Attribution, Describe, Info, Satellite from wyoming.server import AsyncServer, AsyncTcpServer
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
from .event_handler import SatelliteEventHandler
from .satellite import ( from .satellite import (
AlwaysStreamingSatellite, AlwaysStreamingSatellite,
SatelliteBase, SatelliteBase,
@@ -28,7 +24,13 @@ from .settings import (
VadSettings, VadSettings,
WakeSettings, WakeSettings,
) )
from .utils import run_event_command from .utils import (
get_mac_address,
needs_silero,
needs_webrtc,
run_event_command,
split_command,
)
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
@@ -121,6 +123,12 @@ async def main() -> None:
default=1, default=1,
help="Sample channels of wake-command (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 # Voice activity detector
parser.add_argument( parser.add_argument(
@@ -185,6 +193,14 @@ async def main() -> None:
"--error-command", "--error-command",
help="Command to run when an error occurs", 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 # Sounds
parser.add_argument( parser.add_argument(
@@ -214,6 +230,9 @@ async def main() -> None:
help="Host address for zeroconf discovery (default: detect)", 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("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args() args = parser.parse_args()
@@ -250,6 +269,10 @@ async def main() -> None:
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args) _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( wyoming_info = Info(
satellite=Satellite( satellite=Satellite(
name=args.name, name=args.name,
@@ -263,7 +286,7 @@ async def main() -> None:
settings = SatelliteSettings( settings = SatelliteSettings(
mic=MicSettings( mic=MicSettings(
uri=args.mic_uri, uri=args.mic_uri,
command=_split(args.mic_command), command=split_command(args.mic_command),
rate=args.mic_command_rate, rate=args.mic_command_rate,
width=args.mic_command_width, width=args.mic_command_width,
channels=args.mic_command_channels, channels=args.mic_command_channels,
@@ -281,12 +304,15 @@ async def main() -> None:
), ),
wake=WakeSettings( wake=WakeSettings(
uri=args.wake_uri, uri=args.wake_uri,
command=_split(args.wake_command), command=split_command(args.wake_command),
names=args.wake_word_name, names=args.wake_word_name,
refractory_seconds=args.wake_refractory_seconds
if args.wake_refractory_seconds > 0
else None,
), ),
snd=SndSettings( snd=SndSettings(
uri=args.snd_uri, uri=args.snd_uri,
command=_split(args.snd_command), command=split_command(args.snd_command),
rate=args.snd_command_rate, rate=args.snd_command_rate,
width=args.snd_command_width, width=args.snd_command_width,
channels=args.snd_command_channels, channels=args.snd_command_channels,
@@ -296,19 +322,22 @@ async def main() -> None:
), ),
event=EventSettings( event=EventSettings(
uri=args.event_uri, uri=args.event_uri,
startup=_split(args.startup_command), startup=split_command(args.startup_command),
streaming_start=_split(args.streaming_start_command), streaming_start=split_command(args.streaming_start_command),
streaming_stop=_split(args.streaming_stop_command), streaming_stop=split_command(args.streaming_stop_command),
detect=_split(args.detect_command), detect=split_command(args.detect_command),
detection=_split(args.detection_command), detection=split_command(args.detection_command),
transcript=_split(args.transcript_command), transcript=split_command(args.transcript_command),
stt_start=_split(args.stt_start_command), stt_start=split_command(args.stt_start_command),
stt_stop=_split(args.stt_stop_command), stt_stop=split_command(args.stt_stop_command),
synthesize=_split(args.synthesize_command), synthesize=split_command(args.synthesize_command),
tts_start=_split(args.tts_start_command), tts_start=split_command(args.tts_start_command),
tts_stop=_split(args.tts_stop_command), tts_stop=split_command(args.tts_stop_command),
error=_split(args.error_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 satellite: SatelliteBase
@@ -324,7 +353,7 @@ async def main() -> None:
satellite = AlwaysStreamingSatellite(settings) satellite = AlwaysStreamingSatellite(settings)
if args.startup_command: if args.startup_command:
await run_event_command(_split(args.startup_command)) await run_event_command(split_command(args.startup_command))
_LOGGER.info("Ready") _LOGGER.info("Ready")
@@ -349,7 +378,7 @@ async def main() -> None:
args.zeroconf_host, args.zeroconf_host,
) )
satellite_task = asyncio.create_task(satellite.run()) satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
try: try:
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args)) await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
@@ -363,84 +392,12 @@ async def main() -> None:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SatelliteEventHandler(AsyncEventHandler): def run():
"""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__":
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
if __name__ == "__main__":
run()
+56
View File
@@ -0,0 +1,56 @@
"""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_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
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()
+293 -25
View File
@@ -3,9 +3,10 @@ import asyncio
import logging import logging
import math import math
import time import time
import wave
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Callable, Optional, Set, Union from typing import Callable, Dict, Final, Optional, Set, Union
from pyring_buffer import RingBuffer from pyring_buffer import RingBuffer
from wyoming.asr import Transcript from wyoming.asr import Transcript
@@ -14,20 +15,30 @@ from wyoming.client import AsyncClient
from wyoming.error import Error from wyoming.error import Error
from wyoming.event import Event, async_write_event from wyoming.event import Event, async_write_event
from wyoming.mic import MicProcessAsyncClient from wyoming.mic import MicProcessAsyncClient
from wyoming.ping import Ping, Pong
from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped from wyoming.satellite import (
RunSatellite,
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.snd import SndProcessAsyncClient from wyoming.snd import SndProcessAsyncClient
from wyoming.tts import Synthesize from wyoming.tts import Synthesize
from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
from .settings import SatelliteSettings from .settings import SatelliteSettings
from .utils import multiply_volume, run_event_command, wav_to_events from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events
from .vad import SileroVad from .vad import SileroVad
from .webrtc import WebRtcAudio from .webrtc import WebRtcAudio
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
_PONG_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2
class State(Enum): class State(Enum):
NOT_STARTED = auto() NOT_STARTED = auto()
@@ -60,10 +71,30 @@ class SatelliteBase:
self._event_task: Optional[asyncio.Task] = None self._event_task: Optional[asyncio.Task] = None
self._event_queue: "Optional[asyncio.Queue[Event]]" = 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 = asyncio.create_task(self._ping_server(), name="ping")
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 @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""True if not stopping/stopped.""" """True if not stopping/stopped."""
return self._state not in (State.STOPPING, State.STOPPED) return self._state not in (State.STOPPED,)
@property @property
def state(self) -> State: def state(self) -> State:
@@ -107,17 +138,19 @@ class SatelliteBase:
while self.state != State.STOPPED: while self.state != State.STOPPED:
await self._state_changed.wait() 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.""" """Set event writer."""
self.server_id = server_id self.server_id = server_id
self._writer = writer self._writer = writer
_LOGGER.debug("Server set: %s", server_id) _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.""" """Remove writer."""
self.server_id = None self.server_id = None
self._writer = None self._writer = None
_LOGGER.debug("Server disconnected") _LOGGER.debug("Server disconnected")
await self.trigger_server_disonnected()
async def event_to_server(self, event: Event) -> None: async def event_to_server(self, event: Event) -> None:
"""Send an event to the server.""" """Send an event to the server."""
@@ -127,13 +160,38 @@ class SatelliteBase:
try: try:
await async_write_event(event, self._writer) await async_write_event(event, self._writer)
except Exception as err: except Exception as err:
self.clear_server() await self.clear_server()
if isinstance(err, ConnectionResetError): if isinstance(err, ConnectionResetError):
_LOGGER.warning("Server disconnected unexpectedly") _LOGGER.warning("Server disconnected unexpectedly")
else: else:
_LOGGER.exception("Unexpected error sending event to server") _LOGGER.exception("Unexpected error sending event to server")
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 Exception:
_LOGGER.exception("Unexpected error in ping server task")
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def _start(self) -> None: async def _start(self) -> None:
@@ -159,7 +217,17 @@ class SatelliteBase:
async def event_from_server(self, event: Event) -> None: async def event_from_server(self, event: Event) -> None:
"""Called when an event is received from the server.""" """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())
# Enable pinging
self._ping_server_enabled = True
elif Pong.is_type(event.type):
# Response from our ping
self._pong_received_event.set()
elif AudioChunk.is_type(event.type):
# TTS audio # TTS audio
await self.event_to_snd(event) await self.event_to_snd(event)
elif AudioStart.is_type(event.type): elif AudioStart.is_type(event.type):
@@ -204,9 +272,11 @@ class SatelliteBase:
if self.settings.wake.enabled: if self.settings.wake.enabled:
# Local wake word detection # Local wake word detection
start_stage = PipelineStage.ASR start_stage = PipelineStage.ASR
restart_on_end = False
else: else:
# Remote wake word detection # Remote wake word detection
start_stage = PipelineStage.WAKE start_stage = PipelineStage.WAKE
restart_on_end = not self.settings.vad.enabled
if self.settings.snd.enabled: if self.settings.snd.enabled:
# Play TTS response # Play TTS response
@@ -215,7 +285,9 @@ class SatelliteBase:
# No audio output # No audio output
end_stage = PipelineStage.HANDLE 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, restart_on_end=restart_on_end
).event()
await self.event_to_server(run_pipeline) await self.event_to_server(run_pipeline)
await self.forward_event(run_pipeline) await self.forward_event(run_pipeline)
@@ -235,28 +307,30 @@ class SatelliteBase:
"Connecting to mic service: %s", "Connecting to mic service: %s",
self.settings.mic.uri or self.settings.mic.command, 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: if self.settings.snd.enabled:
_LOGGER.debug( _LOGGER.debug(
"Connecting to snd service: %s", "Connecting to snd service: %s",
self.settings.snd.uri or self.settings.snd.command, 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: if self.settings.wake.enabled:
_LOGGER.debug( _LOGGER.debug(
"Connecting to wake service: %s", "Connecting to wake service: %s",
self.settings.wake.uri or self.settings.wake.command, 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: if self.settings.event.enabled:
_LOGGER.debug( _LOGGER.debug(
"Connecting to event service: %s", "Connecting to event service: %s",
self.settings.event.uri or self.settings.event.command, 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") _LOGGER.info("Connected to services")
@@ -479,17 +553,41 @@ class SatelliteBase:
return audio_bytes 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.""" """Send WAV as events to sound service."""
if (not wav_path) or (not self.settings.snd.enabled): if (not wav_path) or (not self.settings.snd.enabled):
return 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( for event in wav_to_events(
wav_path, wav_path,
samples_per_chunk=self.settings.snd.samples_per_chunk, samples_per_chunk=self.settings.snd.samples_per_chunk,
volume_multiplier=self.settings.snd.volume_multiplier, volume_multiplier=self.settings.snd.volume_multiplier,
): ):
await self.event_to_snd(event) 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 # Wake
@@ -528,9 +626,19 @@ class SatelliteBase:
pending: Set[asyncio.Task] = set() pending: Set[asyncio.Task] = set()
async def _disconnect() -> None: async def _disconnect() -> None:
nonlocal to_client_task, from_client_task
try: try:
if wake_client is not None: if wake_client is not None:
await wake_client.disconnect() 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: except Exception:
pass # ignore disconnect errors pass # ignore disconnect errors
@@ -557,12 +665,16 @@ class SatelliteBase:
# Read/write in "parallel" # Read/write in "parallel"
if to_client_task is None: if to_client_task is None:
# From satellite to wake service # 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) pending.add(to_client_task)
if from_client_task is None: if from_client_task is None:
# From wake service to satellite # 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) pending.add(from_client_task)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
@@ -610,6 +722,18 @@ class SatelliteBase:
# Events # 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: async def trigger_streaming_start(self) -> None:
"""Called when audio streaming starts.""" """Called when audio streaming starts."""
await run_event_command(self.settings.event.streaming_start) await run_event_command(self.settings.event.streaming_start)
@@ -627,7 +751,10 @@ class SatelliteBase:
async def trigger_detection(self, detection: Detection) -> None: async def trigger_detection(self, detection: Detection) -> None:
"""Called when wake word is detected.""" """Called when wake word is detected."""
await run_event_command(self.settings.event.detection, detection.name) 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_transcript(self, transcript: Transcript) -> None: async def trigger_transcript(self, transcript: Transcript) -> None:
"""Called when speech-to-text text is received.""" """Called when speech-to-text text is received."""
@@ -717,6 +844,12 @@ class AlwaysStreamingSatellite(SatelliteBase):
super().__init__(settings) super().__init__(settings)
self.is_streaming = False 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: async def event_from_server(self, event: Event) -> None:
await super().event_from_server(event) await super().event_from_server(event)
@@ -725,7 +858,17 @@ class AlwaysStreamingSatellite(SatelliteBase):
_LOGGER.info("Streaming audio") _LOGGER.info("Streaming audio")
await self._send_run_pipeline() await self._send_run_pipeline()
await self.trigger_streaming_start() await self.trigger_streaming_start()
elif Transcript.is_type(event.type): 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") _LOGGER.info("Streaming audio")
# Re-trigger streaming start even though we technically don't stop # Re-trigger streaming start even though we technically don't stop
@@ -735,13 +878,21 @@ class AlwaysStreamingSatellite(SatelliteBase):
async def event_from_mic( async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None self, event: Event, audio_bytes: Optional[bytes] = None
) -> None: ) -> None:
if not self.is_streaming: if (not self.is_streaming) or self.microphone_muted:
return return
if AudioChunk.is_type(event.type): if AudioChunk.is_type(event.type):
# Forward to server # Forward to server
await self.event_to_server(event) 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 +922,43 @@ class VadStreamingSatellite(SatelliteBase):
vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2)) vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2))
self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes) 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")
async def event_from_server(self, event: Event) -> None: async def event_from_server(self, event: Event) -> None:
await super().event_from_server(event) await super().event_from_server(event)
if RunSatellite.is_type(event.type): if RunSatellite.is_type(event.type):
_LOGGER.info("Waiting for speech") _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):
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( async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None self, event: Event, audio_bytes: Optional[bytes] = None
) -> None: ) -> None:
if not AudioChunk.is_type(event.type): if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
return 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 ( if (
self.is_streaming self.is_streaming
and (self.timeout_seconds is not None) and (self.timeout_seconds is not None)
@@ -792,6 +968,10 @@ class VadStreamingSatellite(SatelliteBase):
self.is_streaming = False self.is_streaming = False
self.timeout_seconds = None self.timeout_seconds = None
# Stop debug recording
if self.stt_audio_writer is not None:
self.stt_audio_writer.stop()
# Stop pipeline # Stop pipeline
await self.event_to_server(AudioStop().event()) await self.event_to_server(AudioStop().event())
@@ -800,10 +980,11 @@ class VadStreamingSatellite(SatelliteBase):
if not self.is_streaming: if not self.is_streaming:
# Check VAD # Check VAD
chunk: Optional[AudioChunk] = None
if audio_bytes is None: if audio_bytes is None:
if chunk is None:
# Need to unpack # Need to unpack
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio audio_bytes = chunk.audio
if not self.vad(audio_bytes): if not self.vad(audio_bytes):
@@ -872,26 +1053,83 @@ class WakeStreamingSatellite(SatelliteBase):
super().__init__(settings) super().__init__(settings)
self.is_streaming = False 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
async def event_from_server(self, event: Event) -> None: 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_transcript = False
is_error = False
if RunSatellite.is_type(event.type):
is_run_satellite = True
elif Transcript.is_type(event.type):
is_transcript = True
elif Error.is_type(event.type):
is_error = True
if is_transcript:
# Stop streaming before event_from_server is called because it will # Stop streaming before event_from_server is called because it will
# play the "done" WAV. # play the "done" WAV.
self.is_streaming = False 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) 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:
# Stop streaming and go back to wake word detection
self.is_streaming = False self.is_streaming = False
await self.trigger_streaming_stop() await self.trigger_streaming_stop()
await self._send_wake_detect() await self._send_wake_detect()
_LOGGER.info("Waiting for wake word") _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( async def event_from_mic(
self, event: Event, audio_bytes: Optional[bytes] = None self, event: Event, audio_bytes: Optional[bytes] = None
) -> None: ) -> None:
if not AudioChunk.is_type(event.type): if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
return 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: if self.is_streaming:
# Forward to server # Forward to server
await self.event_to_server(event) await self.event_to_server(event)
@@ -900,12 +1138,42 @@ class WakeStreamingSatellite(SatelliteBase):
await self.event_to_wake(event) await self.event_to_wake(event)
async def event_from_wake(self, event: Event) -> None: async def event_from_wake(self, event: Event) -> None:
if self.is_streaming: if self.is_streaming or (self.server_id is None):
# Not streaming or no server connected
return return
if Detection.is_type(event.type): 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)
self.is_streaming = True self.is_streaming = True
_LOGGER.debug("Streaming audio") _LOGGER.debug("Streaming audio")
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)
await self._send_run_pipeline() await self._send_run_pipeline()
await self.forward_event(event) # forward to event service await self.forward_event(event) # forward to event service
await self.trigger_detection(Detection.from_event(event)) await self.trigger_detection(Detection.from_event(event))
+21 -5
View File
@@ -1,6 +1,7 @@
"""Satellite settings.""" """Satellite settings."""
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -48,6 +49,12 @@ class MicSettings(ServiceSettings):
samples_per_chunk: int = 1024 samples_per_chunk: int = 1024
"""Samples to read at a time from mic command""" """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."""
@property @property
def needs_webrtc(self) -> bool: def needs_webrtc(self) -> bool:
"""True if webrtc audio enhancements are needed.""" """True if webrtc audio enhancements are needed."""
@@ -109,6 +116,9 @@ class WakeSettings(ServiceSettings):
channels: int = 1 channels: int = 1
"""Sample channels in wake word audio""" """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) @dataclass(frozen=True)
class VadSettings: class VadSettings:
@@ -146,6 +156,8 @@ class EventSettings(ServiceSettings):
tts_start: Optional[List[str]] = None tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -153,8 +165,12 @@ class SatelliteSettings:
"""Wyoming satellite settings.""" """Wyoming satellite settings."""
mic: MicSettings mic: MicSettings
vad: VadSettings vad: VadSettings = field(default_factory=VadSettings)
wake: WakeSettings wake: WakeSettings = field(default_factory=WakeSettings)
snd: SndSettings snd: SndSettings = field(default_factory=SndSettings)
event: EventSettings event: EventSettings = field(default_factory=EventSettings)
restart_timeout: float = 5.0 restart_timeout: float = 5.0
debug_recording_dir: Optional[Path] = None
"""Path to directory where debug audio is written."""
+28
View File
@@ -0,0 +1,28 @@
"""Utility methods."""
from .audio import (
AudioBuffer,
DebugAudioWriter,
chunk_samples,
multiply_volume,
wav_to_events,
)
from .misc import (
get_mac_address,
needs_silero,
needs_webrtc,
run_event_command,
split_command,
)
__all__ = [
"AudioBuffer",
"chunk_samples",
"DebugAudioWriter",
"get_mac_address",
"multiply_volume",
"needs_silero",
"needs_webrtc",
"run_event_command",
"split_command",
"wav_to_events",
]
@@ -1,11 +1,12 @@
"""Utilities for Wyoming satellite.""" """Audio utilities."""
import array import array
import asyncio
import logging import logging
import time
import wave import wave
from pathlib import Path 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.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event from wyoming.event import Event
@@ -98,27 +99,6 @@ def chunk_samples(
leftover_chunk_buffer.append(rest_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( def wav_to_events(
wav_path: Union[str, Path], wav_path: Union[str, Path],
samples_per_chunk: int = 1024, samples_per_chunk: int = 1024,
@@ -152,3 +132,74 @@ def wav_to_events(
audio_bytes = wav_file.readframes(samples_per_chunk) audio_bytes = wav_file.readframes(samples_per_chunk)
yield AudioStop(timestamp=timestamp).event() 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
+58
View File
@@ -0,0 +1,58 @@
"""Miscellaneous utilities."""
import argparse
import asyncio
import logging
import shlex
import uuid
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)