Загрузка исходника

This commit is contained in:
2026-05-09 11:49:44 +03:00
commit 87502c55e0
87 changed files with 7700 additions and 0 deletions
Executable
+9
View File
@@ -0,0 +1,9 @@
*
!pyproject.toml
!script/setup
!script/run
!wyoming_satellite/*.py
!wyoming_satellite/utils
!wyoming_satellite/VERSION
!docker/run
!sounds/*.wav
Executable
+17
View File
@@ -0,0 +1,17 @@
.DS_Store
.idea
*.log
tmp/
*.py[cod]
*.egg
*.egg-info/
build
htmlcov
.venv/
.mypy_cache/
__pycache__/
/dist/
/local/
Executable
+6
View File
@@ -0,0 +1,6 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
Executable
+2
View File
@@ -0,0 +1,2 @@
- /.venv/
- /.mypy_cache/
Executable
+47
View File
@@ -0,0 +1,47 @@
# Changelog
## 1.4.1
- Send `played` event to server
## 1.4.0
- Migrate to pyproject.toml
## 1.3.1
- Send audio-start to wake service after detect
## 1.3.0
- Bump to wyoming 1.5.4 (timers)
- Add support for voice timers
- Add `--timer-finished-wav` and `--timer-finished-wav-repeat`
- Add `--timer-started-command`
- Add `--timer-updated-command`
- Add `--timer-cancelled-command`
- Add `--timer-finished-command`
## 1.2.0
- Add `--tts-played-command`
- Add `--mic-seconds-to-mute-after-awake-wav` and `--mic-no-mute-during-awake-wav`
- Send preferred sound format to server
## 1.1.1
- Bump to wyoming 1.5.2 (package fix)
## 1.1.0
- Bump to wyoming 1.5.1
- Send wyoming-satellite version in `info` message
- Ping server if supported (faster awareness of disconnection)
- Support `pause-satellite` message
- Stop streaming/wake word detection as soon as `pause-satellite` is received or server disconnects
- Mute microphone when awake WAV is playing
## 1.0.0
- Initial release
Executable
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.11-slim-bookworm
ENV LANG C.UTF-8
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install --yes --no-install-recommends avahi-utils
WORKDIR /app
COPY sounds/ ./sounds/
COPY script/setup ./script/
COPY pyproject.toml ./
COPY wyoming_satellite/ ./wyoming_satellite/
RUN script/setup
COPY script/run ./script/
COPY docker/run ./
EXPOSE 10700
ENTRYPOINT ["/app/run"]
Executable
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Michael Hansen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Executable
+191
View File
@@ -0,0 +1,191 @@
# Wyoming Satellite
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)
[![An open standard from the Open Home Foundation](https://www.openhomefoundation.org/badges/ohf-open-standard.png)](https://www.openhomefoundation.org/)
---
Requires:
* Python 3.7+ (tested on 3.9+)
* A microphone
## Installation
Install the necessary system dependencies:
``` sh
sudo apt-get install python3-venv python3-pip
```
Then run the install script:
``` sh
script/setup
```
The examples below uses `alsa-utils` to record and play audio:
``` sh
sudo apt-get install alsa-utils
```
## Remote Wake Word Detection
Run the satellite with remote wake word detection:
``` sh
cd wyoming-satellite/
script/run \
--name 'my satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw'
```
This will use the default microphone and playback devices.
Use `arecord -D <DEVICE> ...` if you need to use a different microphone (list them with `arecord -L` and prefer `plughw:` devices).
Use `aplay -D <DEVICE> ...` if you need to use a different playback device (list them with `aplay -L` and prefer `plughw:` devices).
Add `--debug` to print additional logs.
In the [Home Assistant](https://www.home-assistant.io/) settings "Devices & services" page, you should see the satellite discovered automatically. If not, click "Add Integration", choose "Wyoming Protocol", and enter the IP address of the satellite (port 10700).
Audio will be continuously streamed to the server, where wake word detection, etc. will occur.
### Voice Activity Detection
Rather than always streaming audio to Home Assistant, the satellite can wait until speech is detected.
**NOTE:** This will not work on the 32-bit version of Raspberry Pi OS.
Install the dependencies for silero VAD:
``` sh
.venv/bin/pip3 install 'pysilero-vad==1.0.0'
```
Run the satellite with VAD enabled:
``` sh
script/run \
... \
--vad
```
Now, audio will only start streaming once speech has been detected.
## Local Wake Word Detection
Install a wake word detection service, such as [wyoming-openwakeword](https://github.com/rhasspy/wyoming-openwakeword/) and start it:
``` sh
cd wyoming-openwakeword/
script/run \
--uri 'tcp://0.0.0.0:10400' \
--preload-model 'ok_nabu'
```
Add `--debug` to print additional logs. See `--help` for more information.
Included wake words are:
* `ok_nabu`
* `hey_jarvis`
* `alexa`
* `hey_mycroft`
* `hey_rhasspy`
[Community trained wake words](https://github.com/fwartner/home-assistant-wakewords-collection) are also available and can be included with `--custom-model-dir <DIR>` where `<DIR>` contains `.tflite` file(s).
Next, start the satellite with some additional arguments:
``` sh
cd wyoming-satellite/
script/run \
--name 'my satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'ok_nabu'
```
Audio will only be streamed to the server after the wake word has been detected.
Once a wake word has been detected, it can not be detected again for several seconds (called the "refractory period"). You can change this with `--wake-refractory-seconds <SECONDS>`.
Note that `--vad` is unnecessary when connecting to a local instance of openwakeword.
## Sounds
You can play a WAV file when the wake word is detected (locally or remotely), and when speech-to-text has completed:
* `--awake-wav <WAV>` - played when the wake word is detected
* `--done-wav <WAV>` - played when the voice command is finished
* `--timer-finished-wav <WAV>` - played when a timer is finished
If you want to play audio files other than WAV, use [event commands](#event-commands). Specifically, the `--detection-command` to replace `--awake-wav` and `--transcript-command` to replace `--done-wav`.
The timer finished sound can be repeated with `--timer-finished-wav-repeat <repeats> <delay>` where `<repeats>` is the number of times to repeat the WAV, and `<delay>` is the number of seconds to wait between repeats.
## Audio Enhancements
Install the dependencies for webrtc:
``` sh
.venv/bin/pip3 install 'webrtc-noise-gain==1.2.3'
```
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.
## Event Commands
Satellites can respond to events from the server by running commands:
* `--startup-command` - run when satellite starts (no stdin)
* `--detect-command` - wake word detection has started, but not detected yet (no stdin)
* `--streaming-start-command` - audio has started streaming to server (no stdin)
* `--streaming-stop-command` - audio has stopped streaming to server (no stdin)
* `--detection-command` - wake word is detected (wake word name on stdin)
* `--transcript-command` - speech-to-text transcript is returned (text on stdin)
* `--stt-start-command` - user started speaking (no stdin)
* `--stt-stop-command` - user stopped speaking (no stdin)
* `--synthesize-command` - text-to-speech text is returned (text on stdin)
* `--tts-start-command` - text-to-speech response started streaming from server (no stdin)
* `--tts-stop-command` - text-to-speech response stopped streaming from server. Can still being played by snd service (no stdin)
* `--tts-played-command` - text-to-speech audio finished playing (no stdin)
* `--error-command` - an error was sent from the server (text on stdin)
* `--connected-command` - satellite connected to server
* `--disconnected-command` - satellite disconnected from server
* `--timer-started-command` - new timer has started (json on stdin)
* `--timer-updated-command` - timer has been paused/unpaused or has time added/removed (json on stdin)
* `--timer-cancelled-command` - timer has been cancelled (timer id on stdin)
* `--timer-finished-command` - timer has finished (timer id on stdin)
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.
+38
View File
@@ -0,0 +1,38 @@
version: "3.8"
services:
microphone:
image: "rhasspy/wyoming-mic-external"
ports:
- "10600:10600"
devices:
- /dev/snd:/dev/snd
group_add:
- audio
command:
- "--device"
- "sysdefault"
- "--debug"
playback:
image: "rhasspy/wyoming-snd-external"
ports:
- "10601:10601"
devices:
- /dev/snd:/dev/snd
group_add:
- audio
command:
- "--device"
- "sysdefault"
- "--debug"
satellite:
image: "rhasspy/wyoming-satellite"
ports:
- "10700:10700"
command:
- "--name"
- "my satellite"
- "--mic-uri"
- "tcp://microphone:10600"
- "--snd-uri"
- "tcp://playback:10601"
- "--debug"
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
/app/script/run \
--uri tcp://0.0.0.0:10700 "$@"
+396
View File
@@ -0,0 +1,396 @@
# 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 (**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/' \
-e '.[all]'
```
If the installation was successful, you should be able to run:
```sh
script/run --help
```
## Determine Audio Devices
Picking the correct microphone/speaker devices is critical for the satellite to work. We'll do a test recording and playback in this section.
List your available microphones with:
```sh
arecord -L
```
If you have the ReSpeaker 2Mic HAT, you should see:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
For other microphones, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
Record a 5 second sample from your chosen microphone:
```sh
arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t wav -d 5 test.wav
```
Say something while `arecord` is running. If you get errors, try a different microphone device by changing `-D <device>`.
List your available speakers with:
```sh
aplay -L
```
If you have the ReSpeaker 2Mic HAT, you should see:
```
plughw:CARD=seeed2micvoicec,DEV=0
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
Hardware device with all software conversions
```
For other speakers, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
Play back your recorded sample WAV:
```sh
aplay -D plughw:CARD=seeed2micvoicec,DEV=0 test.wav
```
You should hear your recorded sample. If there are problems, try a different speaker device by changing `-D <device>`.
Make note of your microphone and speaker devices for the next step.
## Running the Satellite
In the `wyoming-satellite` directory, run:
```sh
script/run \
--debug \
--name 'my satellite' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
```
Change the `-D <device>` for `arecord` and `aplay` to match the audio devices from the previous section.
You can set `--name <NAME>` to whatever you want, but it should stay the same every time you run the satellite.
In Home Assistant, check the "Devices & services" section in Settings. After some time, you should see your satellite show up as "Discovered" (Wyoming Protocol). Click the "Configure" button and "Submit". Choose the area that your satellite is located, and click "Finish".
Your satellite should say "Streaming audio", and you can use the wake word of your preferred pipeline.
## Create Services
You can run wyoming-satellite as a systemd service by first creating a service file:
``` sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
```text
[Unit]
Description=Wyoming Satellite
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=/home/pi/wyoming-satellite/script/run --name 'my satellite' --uri 'tcp://0.0.0.0:10700' --mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' --snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
WorkingDirectory=/home/pi/wyoming-satellite
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Save the file and exit your editor. Next, enable the service to start at boot and run it:
``` sh
sudo systemctl enable --now wyoming-satellite.service
```
(you may need to hit CTRL+C to get back to a shell prompt)
With the service running, you can view logs in real-time with:
``` sh
journalctl -u wyoming-satellite.service -f
```
If needed, disable and stop the service with:
``` sh
sudo systemctl disable --now wyoming-satellite.service
```
## Audio Enhancements
You can run the satellite with automatic gain control and noise suppression:
``` sh
script/run \
... \
--mic-auto-gain 5 \
--mic-noise-suppression 2
```
Automatic gain control is between 0-31 dbFS, which 31 being the loudest.
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
## Local Wake Word Detection
Install the necessary system dependencies:
```sh
sudo apt-get update
sudo apt-get install --no-install-recommends \
libopenblas-dev
```
From your home directory, install the openWakeWord Wyoming service:
```sh
git clone https://github.com/rhasspy/wyoming-openwakeword.git
cd wyoming-openwakeword
script/setup
```
Create a systemd service for it:
``` sh
sudo systemctl edit --force --full wyoming-openwakeword.service
```
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
```text
[Unit]
Description=Wyoming openWakeWord
[Service]
Type=simple
ExecStart=/home/pi/wyoming-openwakeword/script/run --uri 'tcp://127.0.0.1:10400'
WorkingDirectory=/home/pi/wyoming-openwakeword
Restart=always
RestartSec=1
[Install]
WantedBy=default.target
```
Save the file and exit your editor.
You can now update your satellite service:
``` sh
sudo systemctl edit --force --full wyoming-satellite.service
```
Update just the parts below:
```text
[Unit]
...
Requires=wyoming-openwakeword.service
[Service]
...
ExecStart=/home/pi/wyoming-satellite/script/run ... --wake-uri 'tcp://127.0.0.1:10400' --wake-word-name 'ok_nabu'
...
[Install]
...
```
Reload and restart the satellite service:
``` sh
sudo systemctl daemon-reload
sudo systemctl restart wyoming-satellite.service
```
You should see the wake service get automatically loaded:
``` sh
sudo systemctl status wyoming-satellite.service wyoming-openwakeword.service
```
They should all be "active (running)" and green.
Test out your satellite by saying "ok, nabu" and a voice command. Use `journalctl` to check the logs of services for errors:
``` sh
journalctl -u wyoming-openwakeword.service -f
```
Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service.
## LED Service
Example event services for the ReSpeaker 2Mic and 4Mic HATs are included in `wyoming-satellite/examples` that will change the LED color depending on the satellite state. The example below is for the 2Mic HAT, using `2mic_service.py`. If you're using the 4Mic HAT, use `4mic_service.py` instead as the LEDs and GPIO pins are slightly different.
Install it from your home directory:
```sh
cd wyoming-satellite/examples
python3 -m venv --system-site-packages .venv
.venv/bin/pip3 install --upgrade pip
.venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install 'wyoming==1.5.2'
```
In case you use a ReSpeaker USB 4mic array v2.0, additionally install pixel-ring:
```sh
.venv/bin/pip3 install 'pixel-ring'
```
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
```
If you encounter any issues, you can add the `--debug` argument to the command line to increase the log level.
To control the brightness of the LEDS, use the `--led-brightness ` argument, which accepts integer numbers from 1 to 31.
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 (**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
```
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Installs drivers for the ReSpeaker 2mic and 4mic HATs on Raspberry Pi OS.
# Must be run with sudo.
# Requires: curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
set -eo pipefail
kernel_formatted="$(uname -r | cut -f1,2 -d.)"
driver_url_status="$(curl -ILs https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz | tac | grep -o "^HTTP.*" | cut -f 2 -d' ' | head -1)"
if [ ! "$driver_url_status" = 200 ]; then
echo "Could not find driver for kernel $kernel_formatted"
exit 1
fi
apt-get update
apt-get install --no-install-recommends --yes \
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
temp_dir="$(mktemp -d)"
function finish {
rm -rf "${temp_dir}"
}
trap finish EXIT
pushd "${temp_dir}"
# Download source code to temporary directory
# NOTE: There are different branches in the repo for different kernel versions.
echo 'Downloading source code'
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
tar -xzf -
cd seeed-voicecard-"$kernel_formatted"/
# 1. Build kernel module
echo 'Building kernel module'
ver='0.3'
mod='seeed-voicecard'
src='./'
kernel="$(uname -r)"
marker='0.0.0'
threads="$(getconf _NPROCESSORS_ONLN)"
memory="$(LANG=C free -m|awk '/^Mem:/{print $2}')"
if [ "${memory}" -le 512 ] && [ "${threads}" -gt 2 ]; then
threads=2
fi
mkdir -p "/usr/src/${mod}-${ver}"
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
dkms add -m "${mod}" -v "${ver}"
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
}
mkdir -p "/var/lib/dkms/${mod}/${ver}/${marker}"
# 2. Install kernel module and configure
echo 'Updating boot configuration'
config='/boot/config.txt'
cp seeed-*-voicecard.dtbo /boot/overlays
grep -q "^snd-soc-ac108$" /etc/modules || echo "snd-soc-ac108" >> /etc/modules
sed -i -e 's:#dtparam=i2c_arm=on:dtparam=i2c_arm=on:g' "${config}"
echo "dtoverlay=i2s-mmap" >> "${config}"
echo "dtparam=i2s=on" >> "${config}"
mkdir -p /etc/voicecard
cp *.conf *.state /etc/voicecard
cp seeed-voicecard /usr/bin/
cp seeed-voicecard.service /lib/systemd/system/
systemctl enable --now seeed-voicecard.service
echo 'Done. Please reboot the system.'
popd
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker 2mic HAT, inspired by smart speakers like Alexa and Alice."""
import argparse
import asyncio
import logging
import time
from functools import partial
from math import ceil
from typing import Tuple, List, Optional
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, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
NUM_LEDS = 3
LEDS_GPIO = 12
RGB_MAP = {
"rgb": [3, 2, 1],
"rbg": [3, 1, 2],
"grb": [2, 3, 1],
"gbr": [2, 1, 3],
"brg": [1, 3, 2],
"bgr": [1, 2, 3],
}
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")
parser.add_argument(
"--led-brightness",
type=int,
choices=range(1, 32),
default=31,
help="LED brightness (integer from 1 to 31)",
)
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=False)
led_power.on()
leds = APA102(num_led=NUM_LEDS, global_brightness=args.led_brightness)
# 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)
_PURPLE = (128, 0, 128)
_ORANGE = (255, 165, 0)
_CYAN = (0, 255, 255)
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients, with animations inspired by Alexa and Alice."""
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
self.voice_active = False
self.voice_animation_task: Optional[asyncio.Task] = None
self.streaming_active = False
self.streaming_animation_task: Optional[asyncio.Task] = None
_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.streaming_active = True
if self.streaming_animation_task is None or self.streaming_animation_task.done():
self.streaming_animation_task = asyncio.create_task(self.streaming_animation_loop())
elif Detection.is_type(event.type):
# Как Alexa: directional blue spotlight for wake/listening
await self.rotate_colors([_BLUE, _CYAN, _BLACK], rotations=5, delay=0.2) # Вращение для "просыпания"
self.color(_BLACK)
elif VoiceStarted.is_type(event.type):
self.voice_active = True
if self.voice_animation_task is None or self.voice_animation_task.done():
self.voice_animation_task = asyncio.create_task(self.voice_animation_loop())
elif Transcript.is_type(event.type):
# Как Alexa/Alice: Зеленый успех или эмоция (мигание зеленым для подтверждения)
await self.pulse_color(_GREEN, pulses=2, duration=0.8)
self.color(_BLACK)
elif VoiceStopped.is_type(event.type):
self.voice_active = False
if self.voice_animation_task:
self.voice_animation_task.cancel()
self.voice_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15) # Затухание синего, как конец обработки
elif StreamingStopped.is_type(event.type):
self.streaming_active = False
if self.streaming_animation_task:
self.streaming_animation_task.cancel()
self.streaming_animation_task = None
await self.fade_out(_BLUE, steps=10, delay=0.15)
elif RunSatellite.is_type(event.type):
self.color(_BLACK)
elif SatelliteConnected.is_type(event.type):
# Разовая анимация при подключении, без loop, чтобы в idle не светилось
await self.pulse_color(_GREEN, pulses=2, duration=1.5)
self.color(_BLACK)
elif SatelliteDisconnected.is_type(event.type):
await self.pulse_color(_RED, pulses=3, duration=0.5) # Красный пульс для ошибки, как Alexa purple/red
self.color(_BLACK)
return True
async def voice_animation_loop(self) -> None:
"""Loop анимации для VoiceStarted: как Alexa listening/thinking - alternating blue/cyan."""
try:
while self.voice_active:
await self.gradient([_BLUE, _CYAN, _BLUE], duration=1.5) # Градиент для "thinking"
await self.rotate_colors([_CYAN, _BLUE, _BLACK], rotations=1, delay=0.3) # Вращение для активности
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
async def streaming_animation_loop(self) -> None:
"""Loop для StreamingStarted: pulsing blue как Alexa processing."""
try:
while self.streaming_active:
await self.pulse_color(_BLUE, pulses=1, duration=0.8)
await asyncio.sleep(0.2)
except asyncio.CancelledError:
self.color(_BLACK)
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()
def set_colors(self, colors: List[Tuple[int, int, int]]) -> None:
for i in range(min(NUM_LEDS, len(colors))):
self.leds.set_pixel(i, colors[i][0], colors[i][1], colors[i][2])
self.leds.show()
async def pulse_color(self, rgb: Tuple[int, int, int], pulses: int = 3, duration: float = 0.5) -> None:
steps = 10
for _ in range(pulses):
for bright in range(steps):
self.set_pixel_bright(rgb, (bright + 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
for bright in range(steps, 0, -1):
self.set_pixel_bright(rgb, (bright - 1) / steps * 100)
await asyncio.sleep(duration / (2 * steps))
def set_pixel_bright(self, rgb: Tuple[int, int, int], bright_percent: float) -> None:
for i in range(NUM_LEDS):
self.leds.set_pixel(i, rgb[0], rgb[1], rgb[2], bright_percent)
self.leds.show()
async def rainbow_cycle(self, steps: int = 20, delay: float = 0.05) -> None:
for step in range(steps):
colors = []
for i in range(NUM_LEDS):
r = int((step + i * 85) % 255)
g = int((step + i * 85 + 85) % 255)
b = int((step + i * 85 + 170) % 255)
colors.append((r, g, b))
self.set_colors(colors)
await asyncio.sleep(delay)
self.color(_BLACK)
async def gradient(self, colors: List[Tuple[int, int, int]], duration: float = 1.0) -> None:
steps = 10
for step in range(steps):
blended_colors = []
for i in range(NUM_LEDS):
idx = i % len(colors)
next_idx = (idx + 1) % len(colors)
r = int(colors[idx][0] + (colors[next_idx][0] - colors[idx][0]) * step / steps)
g = int(colors[idx][1] + (colors[next_idx][1] - colors[idx][1]) * step / steps)
b = int(colors[idx][2] + (colors[next_idx][2] - colors[idx][2]) * step / steps)
blended_colors.append((r, g, b))
self.set_colors(blended_colors)
await asyncio.sleep(duration / steps)
async def fade_out(self, start_rgb: Tuple[int, int, int], steps: int = 10, delay: float = 0.1) -> None:
for step in range(steps, 0, -1):
r = int(start_rgb[0] * step / steps)
g = int(start_rgb[1] * step / steps)
b = int(start_rgb[2] * step / steps)
self.color((r, g, b))
await asyncio.sleep(delay)
self.color(_BLACK)
async def rotate_colors(self, colors: List[Tuple[int, int, int]], rotations: int = 3, delay: float = 0.2) -> None:
current_colors = colors[:NUM_LEDS]
for _ in range(rotations):
self.set_colors(current_colors)
await asyncio.sleep(delay)
current_colors = current_colors[-1:] + current_colors[:-1]
# -----------------------------------------------------------------------------
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,
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
_LOGGER.debug("LED brightness: %d", self.global_brightness)
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
self.spi = spidev.SpiDev() # Init the SPI device
self.spi.open(bus, device) # Open SPI port 0, slave device (CS) 1
# Up the speed a bit, so that the LEDs are painted faster
if max_speed_hz:
self.spi.max_speed_hz = max_speed_hz
def clock_start_frame(self):
self.spi.xfer2([0] * 4) # Start frame, 32 zero bits
def clock_end_frame(self):
self.spi.xfer2([0xFF] * 4)
def set_pixel(self, led_num, red, green, blue, bright_percent=100):
if led_num < 0:
return # Pixel is invisible, so ignore
if led_num >= self.num_led:
return # again, invisible
brightness = int(ceil(bright_percent * self.global_brightness / 100.0))
ledstart = (brightness & 0b00011111) | self.LED_START
start_index = 4 * led_num
self.leds[start_index] = ledstart
self.leds[start_index + self.rgb[0]] = red
self.leds[start_index + self.rgb[1]] = green
self.leds[start_index + self.rgb[2]] = blue
def set_pixel_rgb(self, led_num, rgb_color, bright_percent=100):
self.set_pixel(
led_num,
(rgb_color & 0xFF0000) >> 16,
(rgb_color & 0x00FF00) >> 8,
rgb_color & 0x0000FF,
bright_percent,
)
def rotate(self, positions=1):
cutoff = 4 * (positions % self.num_led)
self.leds = self.leds[cutoff:] + self.leds[:cutoff]
def show(self):
self.clock_start_frame()
data = list(self.leds)
while data:
self.spi.xfer2(data[:32])
data = data[32:]
self.clock_end_frame()
def cleanup(self):
self.spi.close() # Close SPI port
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+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
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
name="$(cat)"
echo "Wake word detected: ${name}"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'Satellite has started.'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'Audio streaming to server has started'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'Audio streaming to server has stopped'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'User has started speaking'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'User has stopped speaking'
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
text="$(cat)"
echo "Text to speech text: ${text}"
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
text="$(cat)"
echo "Speech to text transcript: ${text}"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'Text to speech response has started'
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
echo 'Text to speech response has stopped'
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
import argparse
import asyncio
import logging
import time
from functools import partial
from pixel_ring import pixel_ring
from wyoming.event import Event
from wyoming.satellite import (
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
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
pixel_ring.set_color_palette(0x0080FF, 0x007A37)
pixel_ring.think()
await asyncio.sleep(3)
pixel_ring.off()
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args))
except KeyboardInterrupt:
pass
finally:
pixel_ring.off()
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if Detection.is_type(event.type):
_LOGGER.debug("Detection")
pixel_ring.wakeup()
elif VoiceStarted.is_type(event.type):
_LOGGER.debug("VoiceStarted")
pixel_ring.speak()
elif VoiceStopped.is_type(event.type):
_LOGGER.debug("VoiceStopped")
pixel_ring.spin()
elif StreamingStopped.is_type(event.type):
_LOGGER.debug("StreamingStopped")
pixel_ring.off()
elif SatelliteConnected.is_type(event.type):
_LOGGER.debug("SatelliteConnected")
pixel_ring.think()
await asyncio.sleep(2)
pixel_ring.off()
elif Played.is_type(event.type):
_LOGGER.debug("Played")
pixel_ring.off()
elif SatelliteDisconnected.is_type(event.type):
_LOGGER.debug("SatelliteDisconnected")
pixel_ring.mono(0xFF0000)
return True
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+1
View File
@@ -0,0 +1 @@
websockets==12.0
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
from functools import partial
from typing import Optional
import websockets
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--websocket-host", default="localhost")
parser.add_argument("--websocket-port", type=int, default=8675)
#
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")
# Start server
server = AsyncServer.from_uri(args.uri)
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
try:
async with websockets.serve(
partial(websocket_connected, queue),
args.websocket_host,
args.websocket_port,
):
await server.run(partial(WebsocketEventHandler, args, queue))
finally:
queue.put_nowait(None)
# -----------------------------------------------------------------------------
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
try:
while True:
event = await queue.get()
if event is None:
# Stop signal
break
await websocket.send(
json.dumps(
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
)
)
except websockets.ConnectionClosed:
pass
except Exception:
_LOGGER.exception("Error in websocket handler")
# -----------------------------------------------------------------------------
class WebsocketEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
queue: "asyncio.Queue[Optional[Event]]",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.queue = queue
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
self.queue.put_nowait(event)
return True
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Binary file not shown.
+244
View File
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Wyoming satellite timer example">
<meta name="author" content="Michael Hansen">
<title>Wyoming Timer Example</title>
<style>
body {
background-color: black;
}
.timer-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.timer {
display: flex;
width: 100px;
height: auto;
flex-wrap:wrap;
justify-content: center;
align-items: center;
background-color: #03a9f4;
padding: 15px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
color: white;
margin-right: 20px
}
.time {
font-size: 20px;
}
.message {
text-align: center;
width: 90px;
font-size: 24px;
font-weight: bold;
}
.finished {
background-color: red;
}
.paused {
background-color: yellow;
color: black;
}
.blink {
animation: blink-animation 1s linear infinite;
-webkit-animation: blink-animation 1s linear infinite;
}
@keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
@-webkit-keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
</style>
</head>
<body>
<div id="timers" class="timer-container">
</div>
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
<script>
let socket = new WebSocket("ws://localhost:8675")
var timers = []
var finished_timers = 0
function q(selector) {return document.querySelector(selector)}
socket.onopen = function(e) {
console.log("Connected")
};
socket.onmessage = function(event) {
wyo_event = JSON.parse(event.data)
if (wyo_event.type == "timer-started") {
let timer_info = wyo_event.data
timer_info.is_active = true
console.log(timer_info)
timers.push(timer_info)
let timers_elem = q("#timers")
let timer_div = document.createElement("div")
timer_div.id = "timer-" + timer_info.id
timer_div.classList.add("timer")
let message_div = document.createElement("div")
message_div.classList.add("message")
if (timer_info.name) {
message_div.innerHTML = timer_info.name
} else {
if (timer_info.start_hours) {
message_div.innerHTML += timer_info.start_hours + " hr "
}
if (timer_info.start_minutes) {
message_div.innerHTML += timer_info.start_minutes + " min "
}
if (timer_info.start_seconds) {
message_div.innerHTML += timer_info.start_seconds + " sec "
}
}
timer_div.appendChild(message_div)
let time_div = document.createElement("div")
time_div.classList.add("time")
let hours = timer_info.start_hours ?? 0
let minutes = timer_info.start_minutes ?? 0
let seconds = timer_info.start_seconds ?? 0
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0);
timer_div.appendChild(time_div)
timers_elem.appendChild(timer_div)
setTimeout(update_timer, 1000, timer_info)
}
else if (wyo_event.type == "timer-cancelled") {
let timer_info = wyo_event.data
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.remove()
}
}
else if (wyo_event.type == "timer-updated") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].is_active = timer_info.is_active
timers[i].total_seconds = timer_info.total_seconds
break
}
}
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
if (timer_info.is_active) {
timer_div.classList.remove("paused")
} else {
timer_div.classList.add("paused")
}
}
}
else if (wyo_event.type == "timer-finished") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].total_seconds = 0
finished_timers += 1
if (finished_timers == 1) {
q("#audio-finished").play()
}
break
}
}
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.classList.add("finished")
timer_div.classList.add("blink")
timer_div.onclick = function() {
timer_div.remove()
finished_timers = Math.max(0, finished_timers - 1)
if (finished_timers == 0) {
let audio = q("#audio-finished")
audio.pause()
audio.currentTime = 0
}
};
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (time_div) {
time_div.style.opacity = 0
}
}
};
socket.onclose = function(event) {
console.log("Disconnected")
};
socket.onerror = function(error) {
};
function update_timer(timer_info) {
if (timer_info.total_seconds < 0) {
return
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (!time_div) {
return
}
if (!timer_info.is_active) {
// Paused
setTimeout(update_timer, 1000, timer_info)
return
}
if (timer_info.total_seconds > 0) {
timer_info.total_seconds -= 1
}
let total_minutes = Math.floor(timer_info.total_seconds / 60)
let hours = Math.floor(total_minutes / 60)
let minutes = total_minutes % 60
let seconds = timer_info.total_seconds % 60
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0)
setTimeout(update_timer, 1000, timer_info)
};
</script>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
set -e
# Путь к файлу службы
SERVICE_FILE="wyoming-satellite.service"
DEST_PATH="/etc/systemd/system/${SERVICE_FILE}"
# Проверка прав суперпользователя
if [ "$(id -u)" -ne 0 ]; then
echo "Этот скрипт должен быть запущен с правами суперпользователя (sudo)"
exit 1
fi
# Функция для установки службы
install_service() {
echo "Устанавливаю службу Wyoming Satellite..."
# Копируем файл службы
cp "${SERVICE_FILE}" "${DEST_PATH}"
chmod 644 "${DEST_PATH}"
# Делаем скрипт запуска исполняемым
chmod +x "$(pwd)/start_satellite.sh"
# Перезагружаем systemd
systemctl daemon-reload
echo "Служба установлена. Используйте 'sudo systemctl start ${SERVICE_FILE}' для запуска."
}
# Функция для удаления службы
remove_service() {
echo "Удаляю службу Wyoming Satellite..."
# Останавливаем и отключаем службу
systemctl stop "${SERVICE_FILE}" 2>/dev/null || true
systemctl disable "${SERVICE_FILE}" 2>/dev/null || true
# Удаляем файл службы
rm -f "${DEST_PATH}"
# Перезагружаем systemd
systemctl daemon-reload
echo "Служба удалена."
}
# Функция для отображения статуса
show_status() {
echo "Статус службы Wyoming Satellite:"
systemctl status "${SERVICE_FILE}"
}
# Обработка аргументов командной строки
case "$1" in
install)
install_service
;;
remove)
remove_service
;;
status)
show_status
;;
start)
echo "Запускаю службу Wyoming Satellite..."
systemctl start "${SERVICE_FILE}"
;;
stop)
echo "Останавливаю службу Wyoming Satellite..."
systemctl stop "${SERVICE_FILE}"
;;
restart)
echo "Перезапускаю службу Wyoming Satellite..."
systemctl restart "${SERVICE_FILE}"
;;
enable)
echo "Включаю автозапуск службы Wyoming Satellite..."
systemctl enable "${SERVICE_FILE}"
;;
disable)
echo "Отключаю автозапуск службы Wyoming Satellite..."
systemctl disable "${SERVICE_FILE}"
;;
logs)
echo "Журнал службы Wyoming Satellite:"
journalctl -u "${SERVICE_FILE}" -f
;;
*)
echo "Использование: $0 {install|remove|status|start|stop|restart|enable|disable|logs}"
exit 1
;;
esac
exit 0
+1
View File
@@ -0,0 +1 @@
"""Command-line installer."""
+174
View File
@@ -0,0 +1,174 @@
"""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("-e", f"{PROGRAM_DIR}[silerovad]")],
)
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("-e", f"{PROGRAM_DIR}[webrtc]")],
)
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("-e", f"{PROGRAM_DIR}[respeaker]")],
)
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
Executable
+62
View File
@@ -0,0 +1,62 @@
## Интеграция VAD и Wakeword: выводы и план
### 1) Картина текущей архитектуры (Wyoming Satellite)
- **Режимы работы спутника**:
- AlwaysStreaming — всегда стримит аудио, VAD/Wake не участвуют.
- VadStreaming — локальный VAD решает момент старта стриминга; при активации отправляется предречь (pre-roll), опционально действует таймаут на ожидание wakeword.
- WakeStreaming — аудио уходит в локальный wakeword-сервис (Wyoming-протокол); при Detection начинается стриминг на сервер (STT/интенты).
- **VAD по умолчанию**: `SileroVad` с интерфейсом `__call__(audio_bytes|None) -> bool`, поддержка `reset()` через передачу `None`. Предречь хранится в кольцевом буфере, при активации догружается первой.
- **Wakeword**: внешние сервисы по Wyoming (например, openWakeWord, Porcupine, Snowboy), принимают `Audio*` события и отдают `Detection`.
### 2) Что есть в `myproject`
- **VAD** (`myproject/vad_core.py`):
- Использует `webrtcvad.Vad` + энерго-критерии, адаптивный порог, простое шумоподавление и автоусиление.
- Имеет предбуфер и основной буфер записи сегмента речи; по завершении вызывает callback с целым кусочком аудио.
- Ввод аудио сейчас завязан на PyAudio внутри класса, то есть детектор и источник аудио слиты.
- **Wakeword на Vosk** (`myproject/vosk_local_STT.py` + `commands.py`):
- Собирает грамматику из `commands.yaml` (в т.ч. секцию `Wakewords`) и распознаёт текст.
- Факт wakeword определяется как точное совпадение распознанной фразы с элементами из `Wakewords` (то есть post-utterance wakeword через STT).
### 3) Сопоставление и ключевые различия
- В Wyoming VAD — быстрый фрейм-по-фрейм решатель с мгновенным флагом активации, а у вас — сегментатор с внутренним циклом и завершением по тишине.
- В Wyoming wakeword — отдельный потоковый сервис (KWS-подобное поведение), а у вас — определение wakeword как часть STT после завершения фразы.
### 4) Варианты интеграции
1. **Заменить локальный VAD на ваш (рекомендуется этап 2):**
- Выделить из `VADPolling` чистый детектор без PyAudio-ввода (API вида: `process(frame_bytes) -> { started/stopped/speech_detected, prebuffer, ... }`).
- Добавить фабрику VAD в спутник: `settings.vad.system: "silero"|"my_webrtc_vad"`, инициализировать нужную реализацию.
- При активации отдавать предбуфер и стартовать стриминг — совместимо с текущей логикой VadStreaming.
2. **Оформить wakeword на Vosk как Wyoming-сервис (рекомендуется этап 1):**
- Небольшой сервер по Wyoming-протоколу, который:
- подгружает грамматику только из `Wakewords`;
- принимает `AudioStart/AudioChunk/AudioStop` и кормит чанк в `KaldiRecognizer.AcceptWaveform`;
- при совпадении фразы — отправляет `Detection(name=...)` немедленно.
- Подключается как `wake.system: "vosk_wake"`, аналогично `openWakeWord`/`porcupine1`.
### 5) Минимальные изменения кода
- **Для wakeword (этап 1):**
- Реализовать отдельный модуль `vosk_wake` (сервер Wyoming): запуск по TCP, обработка событий, генерация `Detection` по фразам из `Wakewords`.
- Unit-файл/скрипт запуска по аналогии с `local/services/wyoming-wakeword.service` и запись в конфиг `wake.system`.
- **Для VAD (этап 2):**
- Вынести логику детектора из `VADPolling` в отдельный класс без I/O.
- Добавить флаг `vad.system` и фабрику выбора реализации; адаптер, который преобразует `AudioChunk` → фреймы и использует ваш детектор для решения «старт/стоп/предречь».
### 6) Пошаговый план внедрения
1) Wakeword на Vosk (быстрый выигрыш):
- Сделать `vosk_wake` сервис: узкая грамматика = только `Wakewords`, немедленный `Detection` при совпадении.
- Прописать сервис в systemd/скрипте, указать URI и `wake.system`.
- Протестировать в режиме `WakeStreamingSatellite`.
2) Замена VAD:
- Рефакторинг `VADPolling`: отделить детектор от аудио-источника.
- Добавить `vad.system` и внедрить адаптер под текущий интерфейс Wyoming.
- Протестировать в `VadStreamingSatellite` с предбуфером и таймаутом wakeword.
### 7) Риски и примечания
- Vosk как wakeword — это распознавание текста; для «на лету» задержка может быть выше, чем у специализированного KWS. С узкой грамматикой (только wakewords) будет быстрее и устойчивее.
- Ваши улучшения VAD (AGC/NR/адаптивные пороги) полезны; главное — отделить I/O и обеспечить детерминированный фреймовый API, совместимый с Wyoming.
### 8) Резюме
- Этап 1: добавить `vosk_wake` как Wyoming-сервис wakeword — минимум инвазивности и быстрая интеграция.
- Этап 2: внедрить ваш VAD через фабрику и адаптер — получить продвинутую локальную активацию речи, сохранив предречь и таймауты.
Executable
+5
View File
@@ -0,0 +1,5 @@
[mypy]
ignore_missing_imports = true
[mypy-setuptools.*]
ignore_missing_imports = True
Executable
+37
View File
@@ -0,0 +1,37 @@
[MESSAGES CONTROL]
disable=
format,
abstract-method,
cyclic-import,
duplicate-code,
global-statement,
import-outside-toplevel,
inconsistent-return-statements,
locally-disabled,
not-context-manager,
too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
too-many-boolean-expressions,
unnecessary-pass,
unused-argument,
broad-except,
too-many-nested-blocks,
invalid-name,
unused-import,
fixme,
useless-super-delegation,
missing-module-docstring,
missing-class-docstring,
missing-function-docstring,
import-error,
consider-using-with
[FORMAT]
expected-line-ending-format=LF
Executable
+61
View File
@@ -0,0 +1,61 @@
[build-system]
requires = ["setuptools>=62.3"]
build-backend = "setuptools.build_meta"
[project]
name = "wyoming_satellite"
version = "1.4.1"
license = {text = "MIT"}
description = "Wyoming server for remote voice satellite"
readme = "README.md"
authors = [
{name = "Michael Hansen", email = "mike@rhasspy.org"}
]
keywords = ["rhasspy", "wyoming", "satellite"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9.0"
dependencies = [
"wyoming==1.5.4",
"zeroconf==0.88.0",
"pyring-buffer>=1,<2",
]
[project.optional-dependencies]
all = ["pysilero-vad==1.0.0", "webrtc-noise-gain==1.2.3"]
silerovad = ["pysilero-vad==1.0.0"]
webrtc = ["webrtc-noise-gain==1.2.3"]
respeaker = ["gpiozero", "spidev"]
dev = [
"black==22.12.0",
"flake8==6.0.0",
"isort==5.11.3",
"mypy==0.991",
"pylint==2.15.9",
"pytest==7.4.3",
"pytest-asyncio==0.23.3",
"build==1.2.2"
]
[project.scripts]
wyoming-satellite = "wyoming_satellite:__main__.run"
[project.urls]
"Source Code" = "http://github.com/rhasspy/wyoming-satellite"
[tool.setuptools]
platforms = ["any"]
zip-safe = true
include-package-data = true
[tool.setuptools.packages.find]
include = ["wyoming_satellite*"]
exclude = ["tests", "tests.*"]
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker build . \
-t 'rhasspy/wyoming-satellite' "$@"
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_EXAMPLES_DIR = _PROGRAM_DIR / "examples"
_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
_TESTS_DIR = _PROGRAM_DIR / "tests"
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR]
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
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, "-m", "build", "--wheel", "--sdist"])
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/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, "-m", "wyoming_satellite"] + 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" / "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:]
)
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
docker run \
-it \
-p 10700:10700 \
--device /dev/snd \
'rhasspy/wyoming-satellite' "$@"
+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" / "usbmic_service.py")]
+ sys.argv[1:]
)
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
import argparse
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
parser = argparse.ArgumentParser()
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
args = parser.parse_args()
# Create virtual environment
builder = venv.EnvBuilder(with_pip=True)
context = builder.ensure_directories(_VENV_DIR)
builder.create(_VENV_DIR)
# Upgrade dependencies
pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"])
# Install requirements
subprocess.check_call(
pip
+ [
"install",
"--extra-index-url",
"https://www.piwheels.org/simple",
"-f",
"https://synesthesiam.github.io/prebuilt-apps/",
"-e",
str(_PROGRAM_DIR),
]
)
if args.dev:
# Install dev requirements
subprocess.check_call(
pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
)
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:])
Executable
+22
View File
@@ -0,0 +1,22 @@
[flake8]
# To work with Black
max-line-length = 88
# E501: line too long
# W503: Line break occurred before a binary operator
# E203: Whitespace before ':'
# D202 No blank lines allowed after function docstring
# W504 line break after binary operator
ignore =
E501,
W503,
E203,
D202,
W504
[isort]
multi_line_output = 3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
indent = " "
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
+6
View File
@@ -0,0 +1,6 @@
/home/sirmrak/wyoming-satellite/.venv/bin/python local/vosk-wake-dev/script/run \
--uri tcp://0.0.0.0:10400 \
--model-path /home/sirmrak/models/vosk-model-small-ru-0.22 \
--rate 16000 \
--wake-word "привет дроид" #\
# --debug
Executable
+6
View File
@@ -0,0 +1,6 @@
/home/sirmrak/wyoming-satellite/.venv/bin/python local/vosk-wake/script/run \
--uri tcp://0.0.0.0:10400 \
--model-path /home/sirmrak/models/vosk-model-small-ru-0.22 \
--rate 16000 \
--wake-word "привет дроид" \
--debug
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# Делаем текущий проект доступным для Python
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
export PYTHONPATH="${SCRIPT_DIR}"
# Путь к Python-окружению
PY="/home/sirmrak/wyoming-satellite/.venv/bin/python"
# Режим запуска: wake|vad (по умолчанию wake)
MODE="${1:-wake}"
BASE_ARGS=(
--debug
--name 'Wyoming Pi Satellite'
--uri tcp://0.0.0.0:10700
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -q -r 16000 -c 1 -f S16_LE -t raw'
--snd-command 'aplay -D plughw:CARD=0,DEV=0 -q -r 22050 -c 1 -f S16_LE -t raw'
--awake-wav /home/sirmrak/wyoming-satellite/sounds/awake.wav
--mic-noise-suppression 2
--mic-auto-gain 20
--event-uri tcp://127.0.0.1:10500
)
if [[ "$MODE" == "wake" ]]; then
# Локальный wakeword-сервис (Vosk). VAD в этом режиме не используется спутником.
EXTRA_ARGS=(
--wake-uri tcp://127.0.0.1:10400
--wake-word-name "привет дроид" \
#--wake-word-name "привет придурок" \
#--wake-word-name "привет жопа с ушами"
# Добавьте дополнительные фразы при необходимости:
# --wake-word-name "привет дурик" \
# --wake-word-name "привет говнюк"
)
elif [[ "$MODE" == "vad" ]]; then
# Локальный VAD (WebRTC+энергия) без wakeword-сервиса
EXTRA_ARGS=(
--vad
--vad-system webrtc
--vad-trigger-level 5 # ≈100мс при 20мс окне
--vad-energy-threshold 200
--vad-buffer-seconds 1
--vad-wake-word-timeout 5
)
else
echo "Неизвестный режим: $MODE (ожидается: wake|vad)" >&2
exit 1
fi
exec "$PY" -m wyoming_satellite "${BASE_ARGS[@]}" "${EXTRA_ARGS[@]}"
Executable
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Wyoming satellite tests."""
+64
View File
@@ -0,0 +1,64 @@
"""Shared code for Wyoming satellite tests."""
import asyncio
import io
from collections.abc import Iterable
from typing import Optional
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event
AUDIO_START = AudioStart(rate=16000, width=2, channels=1)
AUDIO_STOP = AudioStop()
AUDIO_CHUNK = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
)
class FakeStreamReaderWriter:
def __init__(self) -> None:
self._undrained_data = bytes()
self._value = bytes()
self._data_ready = asyncio.Event()
def write(self, data: bytes) -> None:
self._undrained_data += data
def writelines(self, data: Iterable[bytes]) -> None:
for line in data:
self.write(line)
async def drain(self) -> None:
self._value += self._undrained_data
self._undrained_data = bytes()
self._data_ready.set()
self._data_ready.clear()
async def readline(self) -> bytes:
while b"\n" not in self._value:
await self._data_ready.wait()
with io.BytesIO(self._value) as value_io:
data = value_io.readline()
self._value = self._value[len(data) :]
return data
async def readexactly(self, n: int) -> bytes:
while len(self._value) < n:
await self._data_ready.wait()
data = self._value[:n]
self._value = self._value[n:]
return data
class MicClient(AsyncClient):
async def read_event(self) -> Optional[Event]:
# Send 30ms of audio every 30ms
await asyncio.sleep(AUDIO_CHUNK.seconds)
return AUDIO_CHUNK.event()
async def write_event(self, event: Event) -> None:
# Output only
pass
+222
View File
@@ -0,0 +1,222 @@
import asyncio
import logging
from typing import Final, Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event, async_read_event
from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.tts import Synthesize
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import (
AUDIO_CHUNK,
AUDIO_START,
AUDIO_STOP,
FakeStreamReaderWriter,
MicClient,
)
_LOGGER = logging.getLogger()
TIMEOUT: Final = 1
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._event_ready = asyncio.Event()
self._event: Optional[Event] = None
self._detected: bool = False
async def read_event(self) -> Optional[Event]:
await self._event_ready.wait()
self._event_ready.clear()
return self._event
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
if not self._detected:
self._detected = True
self._event = Detection().event()
self._event_ready.set()
class SndClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
pass
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.detection = asyncio.Event()
self.streaming_started = asyncio.Event()
self.streaming_stopped = asyncio.Event()
self.transcript = asyncio.Event()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.detection.set()
elif StreamingStarted.is_type(event.type):
self.streaming_started.set()
elif StreamingStopped.is_type(event.type):
self.streaming_stopped.set()
elif Transcript.is_type(event.type):
self.transcript.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_satellite() -> None:
mic_client = MicClient()
snd_client = SndClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
return_value=snd_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
snd=SndSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
async def event_from_satellite() -> Optional[Event]:
return await async_read_event(server_io)
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
# Fake server connection
server_io = FakeStreamReaderWriter()
await satellite.set_server("test", server_io) # type: ignore
# Start satellite
await satellite.event_from_server(RunSatellite().event())
# Trigger detection
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert Detection.is_type(event.type), event
# Pipeline should start
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert RunPipeline.is_type(event.type), event
run_pipeline = RunPipeline.from_event(event)
assert run_pipeline.start_stage == PipelineStage.ASR
assert run_pipeline.end_stage == PipelineStage.TTS
# Event service should have received detection
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
# Server should be receiving audio now
assert satellite.is_streaming, "Not streaming"
for _ in range(5):
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming start
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
# Send transcript
await satellite.event_from_server(Transcript(text="test").event())
# Event service should have received transcript
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
# Wait for streaming to stop
while satellite.is_streaming:
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming stop
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
# Fake a TTS response
await satellite.event_from_server(Synthesize(text="test").event())
# Event service should have received synthesize
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
# Audio start, chunk, stop
await satellite.event_from_server(AUDIO_START.event())
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
# Event service does not get audio chunks, just start/stop
await satellite.event_from_server(AUDIO_CHUNK.event())
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
await satellite.event_from_server(AUDIO_STOP.event())
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
# Stop satellite
await satellite.stop()
await satellite_task
+97
View File
@@ -0,0 +1,97 @@
import asyncio
import logging
from pathlib import Path
from typing import Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk
from wyoming.client import AsyncClient
from wyoming.event import Event
from wyoming.satellite import RunSatellite
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import MicClient
_LOGGER = logging.getLogger()
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._detection_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
await self._detection_event.wait()
return Detection().event()
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self._detection_event.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.wake_event = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.wake_event.set()
@pytest.mark.asyncio
async def test_multiple_wakeups(tmp_path: Path) -> None:
mic_client = MicClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
# Fake server connection
satellite.server_id = "test"
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
await satellite.event_from_server(RunSatellite().event())
await asyncio.wait_for(event_client.wake_event.wait(), timeout=1)
event_client.wake_event.clear()
# Stop streaming
await satellite.event_from_server(Transcript("test").event())
# Should not trigger again within refractory period (default: 5 sec)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(event_client.wake_event.wait(), timeout=0.15)
await satellite.stop()
await satellite_task
Executable
+14
View File
@@ -0,0 +1,14 @@
[tox]
env_list =
py{39,310,311,312,313}
minversion = 4.12.1
[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
pytest>=7,<8
pytest-asyncio<1
commands =
pytest {tty:--color=yes} {posargs}
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Wyoming Satellite Service (Wake Mode)
Requires=network.target
After=network.target
[Service]
Type=simple
User=sirmrak
WorkingDirectory=/home/sirmrak/wyoming-satellite
ExecStart=/home/sirmrak/wyoming-satellite/start_satellite.sh
Environment="PYTHONUNBUFFERED=1"
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
BIN
View File
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
"""Voice satellite using the Wyoming protocol."""
import importlib.metadata
from pathlib import Path
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
VadSettings,
WakeSettings,
)
_DIR = Path(__file__).parent
__version__ = importlib.metadata.version("wyoming-satellite")
__all__ = [
"__version__",
"AlwaysStreamingSatellite",
"EventSettings",
"MicSettings",
"SatelliteBase",
"SatelliteSettings",
"SndSettings",
"VadSettings",
"VadStreamingSatellite",
"WakeStreamingSatellite",
"WakeSettings",
]
+504
View File
@@ -0,0 +1,504 @@
"""Main entry point for Wyoming satellite."""
import argparse
import asyncio
import logging
import sys
from functools import partial
from pathlib import Path
from wyoming.info import Attribution, Info, Satellite
from wyoming.server import AsyncServer, AsyncTcpServer
from . import __version__
from .event_handler import SatelliteEventHandler
from .satellite import (
AlwaysStreamingSatellite,
SatelliteBase,
VadStreamingSatellite,
WakeStreamingSatellite,
)
from .settings import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
TimerSettings,
VadSettings,
WakeSettings,
WakeWordAndPipeline,
)
from .utils import (
get_mac_address,
needs_silero,
needs_webrtc,
run_event_command,
split_command,
)
_LOGGER = logging.getLogger()
_DIR = Path(__file__).parent
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
# Microphone input
parser.add_argument("--mic-uri", help="URI of Wyoming microphone service")
parser.add_argument("--mic-command", help="Program to run for microphone input")
parser.add_argument(
"--mic-command-rate",
type=int,
default=16000,
help="Sample rate of mic-command (hertz, default: 16000)",
)
parser.add_argument(
"--mic-command-width",
type=int,
default=2,
help="Sample width of mic-command (bytes, default: 2)",
)
parser.add_argument(
"--mic-command-channels",
type=int,
default=1,
help="Sample channels of mic-command (default: 1)",
)
parser.add_argument(
"--mic-command-samples-per-chunk",
type=int,
default=1024,
help="Sample per chunk for mic-command (default: 1024)",
)
parser.add_argument("--mic-volume-multiplier", type=float, default=1.0)
parser.add_argument(
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
)
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
parser.add_argument(
"--mic-seconds-to-mute-after-awake-wav",
type=float,
default=0.5,
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
)
parser.add_argument(
"--mic-no-mute-during-awake-wav",
action="store_true",
help="Don't mute the microphone while awake wav is being played",
)
parser.add_argument(
"--mic-channel-index",
type=int,
help="Take microphone input from a specific channel (first channel is 0)",
)
# Sound output
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
parser.add_argument("--snd-command", help="Program to run for sound output")
parser.add_argument(
"--snd-command-rate",
type=int,
default=22050,
help="Sample rate of snd-command (hertz, default: 22050)",
)
parser.add_argument(
"--snd-command-width",
type=int,
default=2,
help="Sample width of snd-command (bytes, default: 2)",
)
parser.add_argument(
"--snd-command-channels",
type=int,
default=1,
help="Sample channels of snd-command (default: 1)",
)
parser.add_argument("--snd-volume-multiplier", type=float, default=1.0)
# Local wake word detection
parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service")
parser.add_argument(
"--wake-word-name",
action="append",
default=[],
nargs="+",
metavar=("name", "pipeline"),
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
)
parser.add_argument("--wake-command", help="Program to run for wake word detection")
parser.add_argument(
"--wake-command-rate",
type=int,
default=16000,
help="Sample rate of wake-command (hertz, default: 16000)",
)
parser.add_argument(
"--wake-command-width",
type=int,
default=2,
help="Sample width of wake-command (bytes, default: 2)",
)
parser.add_argument(
"--wake-command-channels",
type=int,
default=1,
help="Sample channels of wake-command (default: 1)",
)
parser.add_argument(
"--wake-refractory-seconds",
type=float,
default=5.0,
help="Seconds after a wake word detection before another detection is handled (default: 5)",
)
# Voice activity detector
parser.add_argument(
"--vad", action="store_true", help="Wait for speech before streaming audio"
)
parser.add_argument(
"--vad-system",
dest="vad_system",
choices=("silero", "webrtc"),
default="silero",
help="VAD implementation to use (default: silero)",
)
parser.add_argument("--vad-threshold", type=float, default=0.5)
parser.add_argument("--vad-trigger-level", type=int, default=1)
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
parser.add_argument(
"--vad-wake-word-timeout",
type=float,
default=5.0,
help="Seconds before going back to waiting for speech when wake word isn't detected",
)
parser.add_argument(
"--vad-energy-threshold",
type=float,
help="Optional RMS energy threshold for 'webrtc' VAD (int16 scale)",
)
# External event handlers
parser.add_argument(
"--event-uri", help="URI of Wyoming service to forward events to"
)
parser.add_argument(
"--startup-command", help="Command run when the satellite starts"
)
parser.add_argument(
"--detect-command", help="Command to run when wake word detection starts"
)
parser.add_argument(
"--detection-command", help="Command to run when wake word is detected"
)
parser.add_argument(
"--transcript-command",
help="Command to run when speech to text transcript is returned",
)
parser.add_argument(
"--stt-start-command",
help="Command to run when the user starts speaking",
)
parser.add_argument(
"--stt-stop-command",
help="Command to run when the user stops speaking",
)
parser.add_argument(
"--synthesize-command",
help="Command to run when text to speech text is returned",
)
parser.add_argument(
"--tts-start-command",
help="Command to run when text to speech response starts",
)
parser.add_argument(
"--tts-stop-command",
help="Command to run when text to speech response stops",
)
parser.add_argument(
"--tts-played-command",
help="Command to run when text-to-speech audio stopped playing",
)
parser.add_argument(
"--streaming-start-command",
help="Command to run when audio streaming starts",
)
parser.add_argument(
"--streaming-stop-command",
help="Command to run when audio streaming stops",
)
parser.add_argument(
"--error-command",
help="Command to run when an error occurs",
)
parser.add_argument(
"--connected-command",
help="Command to run when connected to the server",
)
parser.add_argument(
"--disconnected-command",
help="Command to run when disconnected from the server",
)
parser.add_argument(
"--timer-started-command",
help="Command to run when a timer starts",
)
parser.add_argument(
"--timer-updated-command",
help="Command to run when a timer is paused, resumed, or has time added or removed",
)
parser.add_argument(
"--timer-cancelled-command",
"--timer-canceled-command",
help="Command to run when a timer is cancelled",
)
parser.add_argument(
"--timer-finished-command",
help="Command to run when a timer finishes",
)
# Sounds
parser.add_argument(
"--awake-wav", help="WAV file to play when wake word is detected"
)
parser.add_argument(
"--done-wav", help="WAV file to play when voice command is done"
)
parser.add_argument(
"--timer-finished-wav", help="WAV file to play when a timer finishes"
)
parser.add_argument(
"--timer-finished-wav-repeat",
nargs=2,
metavar=("repeat", "delay"),
type=float,
default=(1, 0),
help="Number of times to play timer finished WAV and delay between repeats in seconds",
)
# Satellite details
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument(
"--name", default="Wyoming Satellite", help="Name of the satellite"
)
parser.add_argument("--area", help="Area name of the satellite")
# Zeroconf
parser.add_argument(
"--no-zeroconf", action="store_true", help="Disable discovery over zeroconf"
)
parser.add_argument(
"--zeroconf-name",
help="Name used for zeroconf discovery (default: MAC from uuid.getnode)",
)
parser.add_argument(
"--zeroconf-host",
help="Host address for zeroconf discovery (default: detect)",
)
#
parser.add_argument(
"--debug-recording-dir", help="Directory to store audio for debugging"
)
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
parser.add_argument(
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
)
parser.add_argument(
"--version",
action="version",
version=__version__,
help="Print version and exit",
)
args = parser.parse_args()
# Validate args
if (not args.mic_uri) and (not args.mic_command):
_LOGGER.fatal("Either --mic-uri or --mic-command is required")
sys.exit(1)
if needs_webrtc(args):
try:
import webrtc_noise_gain # noqa: F401
except ImportError:
_LOGGER.exception("Extras for webrtc are not installed")
sys.exit(1)
if needs_silero(args):
try:
import pysilero_vad # noqa: F401
except ImportError:
_LOGGER.exception("Extras for silerovad are not installed")
sys.exit(1)
if args.awake_wav and (not Path(args.awake_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.awake_wav)
sys.exit(1)
if args.done_wav and (not Path(args.done_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.done_wav)
sys.exit(1)
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
sys.exit(1)
if args.vad and (args.wake_uri or args.wake_command):
_LOGGER.warning("VAD is not used with local wake word detection")
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
)
_LOGGER.debug(args)
if args.debug_recording_dir:
args.debug_recording_dir = Path(args.debug_recording_dir)
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
wyoming_info = Info(
satellite=Satellite(
name=args.name,
area=args.area,
description=args.name,
attribution=Attribution(name="", url=""),
installed=True,
version=__version__,
)
)
settings = SatelliteSettings(
mic=MicSettings(
uri=args.mic_uri,
command=split_command(args.mic_command),
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
samples_per_chunk=args.mic_command_samples_per_chunk,
volume_multiplier=args.mic_volume_multiplier,
auto_gain=args.mic_auto_gain,
noise_suppression=args.mic_noise_suppression,
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
channel_index=args.mic_channel_index,
),
vad=VadSettings(
enabled=args.vad,
system=args.vad_system,
threshold=args.vad_threshold,
trigger_level=args.vad_trigger_level,
buffer_seconds=args.vad_buffer_seconds,
wake_word_timeout=args.vad_wake_word_timeout,
energy_threshold=args.vad_energy_threshold,
),
wake=WakeSettings(
uri=args.wake_uri,
command=split_command(args.wake_command),
names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
],
refractory_seconds=(
args.wake_refractory_seconds
if args.wake_refractory_seconds > 0
else None
),
),
snd=SndSettings(
uri=args.snd_uri,
command=split_command(args.snd_command),
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
volume_multiplier=args.snd_volume_multiplier,
awake_wav=args.awake_wav,
done_wav=args.done_wav,
),
event=EventSettings(
uri=args.event_uri,
startup=split_command(args.startup_command),
streaming_start=split_command(args.streaming_start_command),
streaming_stop=split_command(args.streaming_stop_command),
detect=split_command(args.detect_command),
detection=split_command(args.detection_command),
played=split_command(args.tts_played_command),
transcript=split_command(args.transcript_command),
stt_start=split_command(args.stt_start_command),
stt_stop=split_command(args.stt_stop_command),
synthesize=split_command(args.synthesize_command),
tts_start=split_command(args.tts_start_command),
tts_stop=split_command(args.tts_stop_command),
error=split_command(args.error_command),
connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command),
),
timer=TimerSettings(
started=split_command(args.timer_started_command),
updated=split_command(args.timer_updated_command),
cancelled=split_command(args.timer_cancelled_command),
finished=split_command(args.timer_finished_command),
finished_wav=args.timer_finished_wav,
finished_wav_plays=int(args.timer_finished_wav_repeat[0]),
finished_wav_delay=args.timer_finished_wav_repeat[1],
),
debug_recording_dir=args.debug_recording_dir,
)
satellite: SatelliteBase
if settings.wake.enabled:
# Local wake word detection
satellite = WakeStreamingSatellite(settings)
elif settings.vad.enabled:
# Stream after speech
satellite = VadStreamingSatellite(settings)
else:
# Stream all the time
satellite = AlwaysStreamingSatellite(settings)
if args.startup_command:
await run_event_command(split_command(args.startup_command))
_LOGGER.info("Ready")
# Start server
server = AsyncServer.from_uri(args.uri)
if (not args.no_zeroconf) and isinstance(server, AsyncTcpServer):
from wyoming.zeroconf import register_server
if not args.zeroconf_name:
args.zeroconf_name = get_mac_address()
tcp_server: AsyncTcpServer = server
await register_server(
name=args.zeroconf_name,
port=tcp_server.port,
host=args.zeroconf_host,
)
_LOGGER.debug(
"Zeroconf discovery enabled (name=%s, host=%s)",
args.zeroconf_name,
args.zeroconf_host,
)
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
try:
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
except KeyboardInterrupt:
pass
finally:
await satellite.stop()
await satellite_task
# -----------------------------------------------------------------------------
def run():
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
run()
+57
View File
@@ -0,0 +1,57 @@
"""Wyoming event handler for satellites."""
import argparse
import logging
import time
from wyoming.event import Event
from wyoming.info import Describe, Info
from wyoming.server import AsyncEventHandler
from .satellite import SatelliteBase
_LOGGER = logging.getLogger()
class SatelliteEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
wyoming_info: Info,
satellite: SatelliteBase,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.wyoming_info = wyoming_info
self.client_id = str(time.monotonic_ns())
self.satellite = satellite
# -------------------------------------------------------------------------
async def handle_event(self, event: Event) -> bool:
"""Handle events from the server."""
if Describe.is_type(event.type):
await self.satellite.update_info(self.wyoming_info)
await self.write_event(self.wyoming_info.event())
return True
if self.satellite.server_id is None:
# Take over after a problem occurred
await self.satellite.set_server(self.client_id, self.writer)
elif self.satellite.server_id != self.client_id:
# New connection
_LOGGER.debug("Connection cancelled: %s", self.client_id)
return False
await self.satellite.event_from_server(event)
return True
async def disconnect(self) -> None:
"""Server disconnect."""
if self.satellite.server_id == self.client_id:
await self.satellite.clear_server()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
import argparse
import asyncio
import logging
import time
from functools import partial
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", default="stdio://", 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")
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(ExampleEventHandler, args))
except KeyboardInterrupt:
pass
# -----------------------------------------------------------------------------
class ExampleEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.info(event)
return True
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
BIN
View File
Binary file not shown.
+1512
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
"""Satellite settings."""
from abc import ABC
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass(frozen=True)
class ServiceSettings(ABC):
"""Base class for service settings."""
uri: Optional[str] = None
"""tcp://ip-address:port"""
command: Optional[List[str]] = None
"""program + args"""
reconnect_seconds: float = 3.0
"""Seconds before reconnection attempt is made."""
@property
def enabled(self) -> bool:
"""True if service is enabled."""
return bool(self.uri or self.command)
@dataclass(frozen=True)
class MicSettings(ServiceSettings):
"""Microphone service settings."""
volume_multiplier: float = 1.0
"""Multiplier for raw audio data (1.0 = no change)"""
auto_gain: int = 0
"""Automatic gain control (0-31 dbFS)"""
noise_suppression: int = 0
"""Level of noise suppression (0-4, 4 is max)"""
rate: int = 16000
"""Sample rate of mic audio (hertz)"""
width: int = 2
"""Sample width of mic audio (bytes)"""
channels: int = 1
"""Sample channels in mic audio"""
samples_per_chunk: int = 1024
"""Samples to read at a time from mic command"""
mute_during_awake_wav: bool = True
"""True if microphone audio should be muted while the awake WAV is playing."""
seconds_to_mute_after_awake_wav: float = 0.5
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
channel_index: Optional[int] = None
"""Index of microphone channel to use. When not set, all input channels are used as-is."""
@property
def needs_webrtc(self) -> bool:
"""True if webrtc audio enhancements are needed."""
return self.enabled and ((self.auto_gain > 0) or (self.noise_suppression > 0))
@property
def needs_processing(self) -> bool:
"""True if some audio pre-processing is needed for mic audio."""
return self.enabled and ((self.volume_multiplier != 1.0) or self.needs_webrtc)
@dataclass(frozen=True)
class SndSettings(ServiceSettings):
"""Sound output service settings."""
volume_multiplier: float = 1.0
"""Multiplier for raw audio data (1.0 = no change)"""
awake_wav: Optional[str] = None
"""Path to WAV file played after wake word detection."""
done_wav: Optional[str] = None
"""Path to WAV file played after voice command is recognized."""
rate: int = 22050
"""Sample rate of output audio (hertz)"""
width: int = 2
"""Sample width of output audio (bytes)"""
channels: int = 1
"""Sample channels in output audio"""
samples_per_chunk: int = 1024
"""Samples to write at a time to snd command"""
disconnect_after_stop: bool = True
"""True if snd service should be disconnected after AudioStop."""
@property
def needs_processing(self) -> bool:
"""True if some pre-processing is needed for output audio."""
return self.enabled and (self.volume_multiplier != 1.0)
@dataclass(frozen=True)
class WakeWordAndPipeline:
"""Wake word name + optional pipeline name."""
name: str
pipeline: Optional[str] = None
@dataclass(frozen=True)
class WakeSettings(ServiceSettings):
"""Wake word service settings."""
names: Optional[List[WakeWordAndPipeline]] = None
"""List of wake word names to listen for."""
rate: int = 16000
"""Sample rate of wake word audio (hertz)"""
width: int = 2
"""Sample width of wake word audio (bytes)"""
channels: int = 1
"""Sample channels in wake word audio"""
refractory_seconds: Optional[float] = 5.0
"""Seconds after a wake word detection before another detection is handled."""
@dataclass(frozen=True)
class VadSettings:
"""Voice activity detector settings."""
enabled: bool = False
"""True if VAD should be used."""
system: str = "silero"
"""VAD implementation to use: 'silero' or 'webrtc'"""
threshold: float = 0.5
"""Silence threshold (0-1, 1 is speech)"""
trigger_level: int = 1
"""Number of chunks to cross threshold before activation."""
energy_threshold: Optional[float] = None
"""Optional RMS energy threshold for 'webrtc' VAD (int16 scale)."""
buffer_seconds: float = 2.0
"""Seconds of audio before activation to keep/stream"""
wake_word_timeout: Optional[float] = 5.0
"""Seconds before going back to sleep if wake word is not detected."""
@dataclass(frozen=True)
class EventSettings(ServiceSettings):
"""External event service settings."""
startup: Optional[List[str]] = None
streaming_start: Optional[List[str]] = None
streaming_stop: Optional[List[str]] = None
detect: Optional[List[str]] = None
detection: Optional[List[str]] = None
played: Optional[List[str]] = None
transcript: Optional[List[str]] = None
stt_start: Optional[List[str]] = None
stt_stop: Optional[List[str]] = None
synthesize: Optional[List[str]] = None
tts_start: Optional[List[str]] = None
tts_stop: Optional[List[str]] = None
error: Optional[List[str]] = None
connected: Optional[List[str]] = None
disconnected: Optional[List[str]] = None
@dataclass(frozen=True)
class TimerSettings:
"""Voice timer settings."""
started: Optional[List[str]] = None
"""Command to run when a timer starts."""
updated: Optional[List[str]] = None
"""Command to run when a timer is paused, resumed, or has time added or removed."""
cancelled: Optional[List[str]] = None
"""Command to run when a timer is cancelled."""
finished: Optional[List[str]] = None
"""Command to run when a timer finishes."""
finished_wav: Optional[str] = None
"""WAV file to play when a timer finishes."""
finished_wav_plays: int = 1
"""Number of times to play finished WAV."""
finished_wav_delay: float = 0
"""Delay in seconds between repeats of finished WAV."""
@dataclass(frozen=True)
class SatelliteSettings:
"""Wyoming satellite settings."""
mic: MicSettings
vad: VadSettings = field(default_factory=VadSettings)
wake: WakeSettings = field(default_factory=WakeSettings)
snd: SndSettings = field(default_factory=SndSettings)
event: EventSettings = field(default_factory=EventSettings)
timer: TimerSettings = field(default_factory=TimerSettings)
restart_timeout: float = 5.0
debug_recording_dir: Optional[Path] = None
"""Path to directory where debug audio is written."""
+30
View File
@@ -0,0 +1,30 @@
"""Utility methods."""
from .audio import (
AudioBuffer,
DebugAudioWriter,
chunk_samples,
multiply_volume,
wav_to_events,
)
from .misc import (
get_mac_address,
needs_silero,
needs_webrtc,
normalize_wake_word,
run_event_command,
split_command,
)
__all__ = [
"AudioBuffer",
"chunk_samples",
"DebugAudioWriter",
"get_mac_address",
"multiply_volume",
"needs_silero",
"needs_webrtc",
"normalize_wake_word",
"run_event_command",
"split_command",
"wav_to_events",
]
+205
View File
@@ -0,0 +1,205 @@
"""Audio utilities."""
import array
import logging
import time
import wave
from pathlib import Path
from typing import Iterable, Iterator, Optional, Union
from pyring_buffer import RingBuffer
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event
_LOGGER = logging.getLogger()
class AudioBuffer:
"""Fixed-sized audio buffer with variable internal length."""
def __init__(self, maxlen: int) -> None:
"""Initialize buffer."""
self._buffer = bytearray(maxlen)
self._length = 0
@property
def length(self) -> int:
"""Get number of bytes currently in the buffer."""
return self._length
def clear(self) -> None:
"""Clear the buffer."""
self._length = 0
def append(self, data: bytes) -> None:
"""Append bytes to the buffer, increasing the internal length."""
data_len = len(data)
if (self._length + data_len) > len(self._buffer):
raise ValueError("Length cannot be greater than buffer size")
self._buffer[self._length : self._length + data_len] = data
self._length += data_len
def to_bytes(self) -> bytes:
"""Convert written portion of buffer to bytes."""
return bytes(self._buffer[: self._length])
def __len__(self) -> int:
"""Get the number of bytes currently in the buffer."""
return self._length
def __bool__(self) -> bool:
"""Return True if there are bytes in the buffer."""
return self._length > 0
def multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
"""Multiplies 16-bit PCM samples by a constant."""
def _clamp(val: float) -> float:
"""Clamp to signed 16-bit."""
return max(-32768, min(32767, val))
return array.array(
"h",
(int(_clamp(value * volume_multiplier)) for value in array.array("h", chunk)),
).tobytes()
def chunk_samples(
samples: bytes,
bytes_per_chunk: int,
leftover_chunk_buffer: AudioBuffer,
) -> Iterable[bytes]:
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
# Extend leftover chunk, but not enough samples to complete it
leftover_chunk_buffer.append(samples)
return
next_chunk_idx = 0
if leftover_chunk_buffer:
# Add to leftover chunk from previous call(s).
bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer)
leftover_chunk_buffer.append(samples[:bytes_to_copy])
next_chunk_idx = bytes_to_copy
# Process full chunk in buffer
yield leftover_chunk_buffer.to_bytes()
leftover_chunk_buffer.clear()
while next_chunk_idx < len(samples) - bytes_per_chunk + 1:
# Process full chunk
yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk]
next_chunk_idx += bytes_per_chunk
# Capture leftover chunks
if rest_samples := samples[next_chunk_idx:]:
leftover_chunk_buffer.append(rest_samples)
def wav_to_events(
wav_path: Union[str, Path],
samples_per_chunk: int = 1024,
volume_multiplier: float = 1.0,
) -> Iterator[Event]:
"""Load WAV audio for playback on an event (wake/done)."""
with wave.open(str(wav_path), "rb") as wav_file:
rate = wav_file.getframerate()
width = wav_file.getsampwidth()
channels = wav_file.getnchannels()
timestamp = 0
yield AudioStart(
rate=rate, width=width, channels=channels, timestamp=timestamp
).event()
audio_bytes = wav_file.readframes(samples_per_chunk)
while audio_bytes:
if volume_multiplier != 1.0:
audio_bytes = multiply_volume(audio_bytes, volume_multiplier)
chunk = AudioChunk(
rate=rate,
width=width,
channels=channels,
audio=audio_bytes,
timestamp=timestamp,
)
yield chunk.event()
timestamp += int(chunk.seconds * 1000)
audio_bytes = wav_file.readframes(samples_per_chunk)
yield AudioStop(timestamp=timestamp).event()
class DebugAudioWriter:
def __init__(
self,
dir_path: Union[str, Path],
suffix: str,
rate: int = 16000,
width: int = 2,
channels: int = 1,
ring_buffer_size: Optional[int] = None,
) -> None:
self.dir_path = Path(dir_path)
self.suffix = suffix
self.rate = rate
self.width = width
self.channels = channels
self._wav_path: Optional[Path] = None
self._wav_writer: Optional[wave.Wave_write] = None
# If ring buffer size is set, we will hold audio in a ring buffer
# instead of directly writing it to disk.
#
# This allows only the last few seconds of wake word audio to be stored.
self._ring_buffer: Optional[RingBuffer] = None
if ring_buffer_size is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer = RingBuffer(ring_buffer_size)
def start(self, timestamp: Optional[int] = None) -> None:
self.stop()
if timestamp is None:
timestamp = time.monotonic_ns()
self._wav_path = self.dir_path / f"{timestamp}-{self.suffix}.wav"
self._wav_path.parent.mkdir(parents=True, exist_ok=True)
self._wav_writer = wave.open(str(self._wav_path), "wb")
self._wav_writer.setframerate(self.rate)
self._wav_writer.setsampwidth(self.width)
self._wav_writer.setnchannels(self.channels)
_LOGGER.debug("Started recording to %s", self._wav_path)
def write(self, audio: bytes) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Hold audio in a ring buffer before writing
self._ring_buffer.put(audio)
else:
# Write directly to disk
self._wav_writer.writeframes(audio)
def stop(self) -> None:
if self._wav_writer is None:
return
if self._ring_buffer is not None:
# Write all audio now and reset buffer
self._wav_writer.writeframes(self._ring_buffer.getvalue())
self._ring_buffer = RingBuffer(self._ring_buffer.maxlen)
self._wav_writer.close()
self._wav_writer = None
_LOGGER.debug("Stopped recording to %s", self._wav_path)
self._wav_path = None
+90
View File
@@ -0,0 +1,90 @@
"""Miscellaneous utilities."""
import argparse
import asyncio
import json
import logging
import re
import shlex
import unicodedata
import uuid
from functools import lru_cache
from typing import List, Optional, Union
from wyoming.event import Eventable
_LOGGER = logging.getLogger()
async def run_event_command(
command: Optional[List[str]], command_input: Optional[Union[str, Eventable]] = None
) -> None:
"""Run a custom event command with optional input."""
if not command:
return
if isinstance(command_input, Eventable):
# Convert event to JSON
event_dict = command_input.event().to_dict()
command_input = json.dumps(event_dict, ensure_ascii=False)
_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 and (getattr(args, "vad_system", "silero") == "silero")
def split_command(command: Optional[str]) -> Optional[List[str]]:
"""Split command line program/args if not empty."""
if not command:
return None
return shlex.split(command)
@lru_cache
def normalize_wake_word(wake_word: str) -> str:
"""Normalizes a wake word name for comparison."""
# Lower case
wake_word = wake_word.strip().casefold()
# Remove version numbers like v1.0
wake_word = re.sub(r"v[0-9]+(\.[0-9])+", "", wake_word)
# Replace anything besides letters/numbers with whitespace
wake_word = "".join(
c if unicodedata.category(c) in ("Ll", "Nd") else " " for c in wake_word
)
# Normalize whitespace
wake_word = " ".join(wake_word.strip().split())
return wake_word
+88
View File
@@ -0,0 +1,88 @@
"""Voice activity detection."""
from typing import Optional
import math
import array
class SileroVad:
"""Voice activity detection with silero VAD."""
def __init__(self, threshold: float, trigger_level: int) -> None:
from pysilero_vad import SileroVoiceActivityDetector
self.detector = SileroVoiceActivityDetector()
self.threshold = threshold
self.trigger_level = trigger_level
self._activation = 0
def __call__(self, audio_bytes: Optional[bytes]) -> bool:
if audio_bytes is None:
# Reset
self._activation = 0
self.detector.reset()
return False
if self.detector(audio_bytes) >= self.threshold:
# Speech detected
self._activation += 1
if self._activation >= self.trigger_level:
self._activation = 0
return True
else:
# Silence detected
self._activation = max(0, self._activation - 1)
return False
class WebRtcVadEnergy:
"""Voice activity detection using webrtcvad + RMS energy threshold.
Expects 16-bit mono PCM at 16Khz. Chunks are internally processed in 20ms windows.
"""
def __init__(self, trigger_level: int, energy_threshold: Optional[float] = None) -> None:
import webrtcvad # type: ignore
self.vad = webrtcvad.Vad(3)
self.trigger_level = trigger_level
self.energy_threshold = energy_threshold if energy_threshold is not None else 200.0
self._activation = 0
# 20ms @16kHz, 16-bit mono
self._samples_per_window = 320
self._bytes_per_window = self._samples_per_window * 2
def __call__(self, audio_bytes: Optional[bytes]) -> bool:
if audio_bytes is None:
# Reset
self._activation = 0
return False
# Process in 20ms windows as required by webrtcvad
for i in range(0, len(audio_bytes) - (len(audio_bytes) % self._bytes_per_window), self._bytes_per_window):
window = audio_bytes[i : i + self._bytes_per_window]
# RMS energy
pcm = array.array("h")
pcm.frombytes(window)
if len(pcm) == 0:
continue
rms = math.sqrt(sum(int(s) * int(s) for s in pcm) / len(pcm))
is_speech = False
try:
is_speech = self.vad.is_speech(window, 16000) and (rms >= float(self.energy_threshold))
except Exception:
# If VAD errors on malformed window, skip
is_speech = False
if is_speech:
self._activation += 1
if self._activation >= self.trigger_level:
self._activation = 0
return True
else:
self._activation = max(0, self._activation - 1)
return False
+28
View File
@@ -0,0 +1,28 @@
"""webrtc audio processing."""
from typing import Final
from .utils import AudioBuffer, chunk_samples
class WebRtcAudio:
"""Audio processing using webrtc."""
_sub_chunk_samples: Final = 160 # 10ms @ 16Khz
_sub_chunk_bytes: Final = _sub_chunk_samples * 2 # 16-bit
def __init__(self, auto_gain: int, noise_suppression: int) -> None:
from webrtc_noise_gain import AudioProcessor
self.audio_processor = AudioProcessor(auto_gain, noise_suppression)
self.audio_buffer = AudioBuffer(self._sub_chunk_bytes)
def __call__(self, audio_bytes: bytes) -> bytes:
"""Process in 10ms chunks."""
clean_chunk = bytes()
for sub_chunk in chunk_samples(
audio_bytes, self._sub_chunk_bytes, self.audio_buffer
):
result = self.audio_processor.Process10ms(sub_chunk)
clean_chunk += result.audio
return clean_chunk