commit 87502c55e0cc7bde33e114e3a63d795175060dd8 Author: sirmrak Date: Sat May 9 11:49:44 2026 +0300 Загрузка исходника diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..c53b52f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!pyproject.toml +!script/setup +!script/run +!wyoming_satellite/*.py +!wyoming_satellite/utils +!wyoming_satellite/VERSION +!docker/run +!sounds/*.wav diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..8798bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +.idea +*.log +tmp/ + +*.py[cod] +*.egg +*.egg-info/ +build +htmlcov + +.venv/ +.mypy_cache/ +__pycache__/ + +/dist/ +/local/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100755 index 0000000..ba2778d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,6 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/.projectile b/.projectile new file mode 100755 index 0000000..ce66c37 --- /dev/null +++ b/.projectile @@ -0,0 +1,2 @@ +- /.venv/ +- /.mypy_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100755 index 0000000..7c3fdf5 --- /dev/null +++ b/CHANGELOG.md @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..247f4f0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..0875822 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100755 index 0000000..1ba070b --- /dev/null +++ b/README.md @@ -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 ...` if you need to use a different microphone (list them with `arecord -L` and prefer `plughw:` devices). +Use `aplay -D ...` 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 ` where `` 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 `. + +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 ` - played when the wake word is detected +* `--done-wav ` - played when the voice command is finished +* `--timer-finished-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 ` where `` is the number of times to repeat the WAV, and `` 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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..664d016 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/docker/run b/docker/run new file mode 100755 index 0000000..2e6876d --- /dev/null +++ b/docker/run @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +/app/script/run \ + --uri tcp://0.0.0.0:10700 "$@" diff --git a/docs/tutorial_2mic.md b/docs/tutorial_2mic.md new file mode 100755 index 0000000..21ffd86 --- /dev/null +++ b/docs/tutorial_2mic.md @@ -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 `. + +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 `. + +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 ` for `arecord` and `aplay` to match the audio devices from the previous section. +You can set `--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. diff --git a/docs/tutorial_installer.md b/docs/tutorial_installer.md new file mode 100755 index 0000000..c6b16af --- /dev/null +++ b/docs/tutorial_installer.md @@ -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 +``` diff --git a/etc/install-respeaker-drivers.sh b/etc/install-respeaker-drivers.sh new file mode 100755 index 0000000..4e7ff22 --- /dev/null +++ b/etc/install-respeaker-drivers.sh @@ -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 diff --git a/examples/2mic_service.py b/examples/2mic_service.py new file mode 100755 index 0000000..1195a60 --- /dev/null +++ b/examples/2mic_service.py @@ -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 \ No newline at end of file diff --git a/examples/4mic_service.py b/examples/4mic_service.py new file mode 100755 index 0000000..221528f --- /dev/null +++ b/examples/4mic_service.py @@ -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 diff --git a/examples/commands/detection.sh b/examples/commands/detection.sh new file mode 100755 index 0000000..f013b00 --- /dev/null +++ b/examples/commands/detection.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +name="$(cat)" +echo "Wake word detected: ${name}" diff --git a/examples/commands/startup.sh b/examples/commands/startup.sh new file mode 100755 index 0000000..f03b925 --- /dev/null +++ b/examples/commands/startup.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'Satellite has started.' diff --git a/examples/commands/streaming_start.sh b/examples/commands/streaming_start.sh new file mode 100755 index 0000000..cc6fd20 --- /dev/null +++ b/examples/commands/streaming_start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'Audio streaming to server has started' diff --git a/examples/commands/streaming_stop.sh b/examples/commands/streaming_stop.sh new file mode 100755 index 0000000..83e3c8e --- /dev/null +++ b/examples/commands/streaming_stop.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'Audio streaming to server has stopped' diff --git a/examples/commands/stt_start.sh b/examples/commands/stt_start.sh new file mode 100755 index 0000000..941e8d0 --- /dev/null +++ b/examples/commands/stt_start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'User has started speaking' diff --git a/examples/commands/stt_stop.sh b/examples/commands/stt_stop.sh new file mode 100755 index 0000000..219f601 --- /dev/null +++ b/examples/commands/stt_stop.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'User has stopped speaking' diff --git a/examples/commands/synthesize.sh b/examples/commands/synthesize.sh new file mode 100755 index 0000000..9a0da04 --- /dev/null +++ b/examples/commands/synthesize.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +text="$(cat)" +echo "Text to speech text: ${text}" diff --git a/examples/commands/transcript.sh b/examples/commands/transcript.sh new file mode 100755 index 0000000..0c37b0f --- /dev/null +++ b/examples/commands/transcript.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +text="$(cat)" +echo "Speech to text transcript: ${text}" diff --git a/examples/commands/tts_start.sh b/examples/commands/tts_start.sh new file mode 100755 index 0000000..c711c40 --- /dev/null +++ b/examples/commands/tts_start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'Text to speech response has started' diff --git a/examples/commands/tts_stop.sh b/examples/commands/tts_stop.sh new file mode 100755 index 0000000..cdbc1ce --- /dev/null +++ b/examples/commands/tts_stop.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo 'Text to speech response has stopped' diff --git a/examples/usbmic_service.py b/examples/usbmic_service.py new file mode 100755 index 0000000..dbc7fd3 --- /dev/null +++ b/examples/usbmic_service.py @@ -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 diff --git a/examples/websocket-service/requirements.txt b/examples/websocket-service/requirements.txt new file mode 100755 index 0000000..02e1832 --- /dev/null +++ b/examples/websocket-service/requirements.txt @@ -0,0 +1 @@ +websockets==12.0 diff --git a/examples/websocket-service/server.py b/examples/websocket-service/server.py new file mode 100755 index 0000000..6fbf84c --- /dev/null +++ b/examples/websocket-service/server.py @@ -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 diff --git a/examples/websocket-service/timer_finished.wav b/examples/websocket-service/timer_finished.wav new file mode 100755 index 0000000..40a97bc Binary files /dev/null and b/examples/websocket-service/timer_finished.wav differ diff --git a/examples/websocket-service/timers.html b/examples/websocket-service/timers.html new file mode 100755 index 0000000..0c6777b --- /dev/null +++ b/examples/websocket-service/timers.html @@ -0,0 +1,244 @@ + + + + + + + + + + + Wyoming Timer Example + + + + +
+
+ + + + + diff --git a/install_service.sh b/install_service.sh new file mode 100755 index 0000000..1d8da00 --- /dev/null +++ b/install_service.sh @@ -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 \ No newline at end of file diff --git a/installer/__init__.py b/installer/__init__.py new file mode 100755 index 0000000..c776e30 --- /dev/null +++ b/installer/__init__.py @@ -0,0 +1 @@ +"""Command-line installer.""" diff --git a/installer/__main__.py b/installer/__main__.py new file mode 100755 index 0000000..9f43e84 --- /dev/null +++ b/installer/__main__.py @@ -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() diff --git a/installer/const.py b/installer/const.py new file mode 100755 index 0000000..61318dc --- /dev/null +++ b/installer/const.py @@ -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) diff --git a/installer/dataclasses_json.py b/installer/dataclasses_json.py new file mode 100755 index 0000000..c2d1035 --- /dev/null +++ b/installer/dataclasses_json.py @@ -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__) diff --git a/installer/drivers.py b/installer/drivers.py new file mode 100755 index 0000000..1d62227 --- /dev/null +++ b/installer/drivers.py @@ -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 diff --git a/installer/microphone.py b/installer/microphone.py new file mode 100755 index 0000000..a6b32e0 --- /dev/null +++ b/installer/microphone.py @@ -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"], + ) diff --git a/installer/packages.py b/installer/packages.py new file mode 100755 index 0000000..1e3bacd --- /dev/null +++ b/installer/packages.py @@ -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 diff --git a/installer/satellite.py b/installer/satellite.py new file mode 100755 index 0000000..098c542 --- /dev/null +++ b/installer/satellite.py @@ -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 diff --git a/installer/services.py b/installer/services.py new file mode 100755 index 0000000..b801f83 --- /dev/null +++ b/installer/services.py @@ -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") diff --git a/installer/speakers.py b/installer/speakers.py new file mode 100755 index 0000000..bffbdbb --- /dev/null +++ b/installer/speakers.py @@ -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) diff --git a/installer/wake_word.py b/installer/wake_word.py new file mode 100755 index 0000000..72c2c03 --- /dev/null +++ b/installer/wake_word.py @@ -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 diff --git a/installer/whiptail.py b/installer/whiptail.py new file mode 100755 index 0000000..4c4d911 --- /dev/null +++ b/installer/whiptail.py @@ -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 diff --git a/integrate.md b/integrate.md new file mode 100755 index 0000000..3c29a48 --- /dev/null +++ b/integrate.md @@ -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 через фабрику и адаптер — получить продвинутую локальную активацию речи, сохранив предречь и таймауты. + + diff --git a/mypy.ini b/mypy.ini new file mode 100755 index 0000000..261cf49 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +ignore_missing_imports = true + +[mypy-setuptools.*] +ignore_missing_imports = True diff --git a/pylintrc b/pylintrc new file mode 100755 index 0000000..22a70d0 --- /dev/null +++ b/pylintrc @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100755 index 0000000..89bdab4 --- /dev/null +++ b/pyproject.toml @@ -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.*"] diff --git a/script/build_docker b/script/build_docker new file mode 100755 index 0000000..32b18f3 --- /dev/null +++ b/script/build_docker @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +docker build . \ + -t 'rhasspy/wyoming-satellite' "$@" diff --git a/script/format b/script/format new file mode 100755 index 0000000..24d4853 --- /dev/null +++ b/script/format @@ -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) diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..ceabca0 --- /dev/null +++ b/script/lint @@ -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) diff --git a/script/package b/script/package new file mode 100755 index 0000000..b124a6b --- /dev/null +++ b/script/package @@ -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"]) diff --git a/script/run b/script/run new file mode 100755 index 0000000..a648150 --- /dev/null +++ b/script/run @@ -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:]) diff --git a/script/run_2mic b/script/run_2mic new file mode 100755 index 0000000..c94f2a2 --- /dev/null +++ b/script/run_2mic @@ -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:] +) diff --git a/script/run_4mic b/script/run_4mic new file mode 100755 index 0000000..951c8c6 --- /dev/null +++ b/script/run_4mic @@ -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:] +) diff --git a/script/run_docker b/script/run_docker new file mode 100755 index 0000000..09beb9f --- /dev/null +++ b/script/run_docker @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +docker run \ + -it \ + -p 10700:10700 \ + --device /dev/snd \ + 'rhasspy/wyoming-satellite' "$@" diff --git a/script/run_usbmic b/script/run_usbmic new file mode 100755 index 0000000..665cdbe --- /dev/null +++ b/script/run_usbmic @@ -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:] +) diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..e0fe6c4 --- /dev/null +++ b/script/setup @@ -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]"] + ) diff --git a/script/test b/script/test new file mode 100755 index 0000000..5ad78a8 --- /dev/null +++ b/script/test @@ -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:]) diff --git a/setup.cfg b/setup.cfg new file mode 100755 index 0000000..0076bbf --- /dev/null +++ b/setup.cfg @@ -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 = " " diff --git a/sounds/awake.wav b/sounds/awake.wav new file mode 100755 index 0000000..8cb6535 Binary files /dev/null and b/sounds/awake.wav differ diff --git a/sounds/done.wav b/sounds/done.wav new file mode 100755 index 0000000..d76ec5b Binary files /dev/null and b/sounds/done.wav differ diff --git a/sounds/listen.wav b/sounds/listen.wav new file mode 100755 index 0000000..cb4f29f Binary files /dev/null and b/sounds/listen.wav differ diff --git a/sounds/timer_finished.wav b/sounds/timer_finished.wav new file mode 100755 index 0000000..40a97bc Binary files /dev/null and b/sounds/timer_finished.wav differ diff --git a/sounds/volume.wav b/sounds/volume.wav new file mode 100755 index 0000000..40a97bc Binary files /dev/null and b/sounds/volume.wav differ diff --git a/start-dev.sh b/start-dev.sh new file mode 100755 index 0000000..f6e7615 --- /dev/null +++ b/start-dev.sh @@ -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 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..158c425 --- /dev/null +++ b/start.sh @@ -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 diff --git a/start_satellite.sh b/start_satellite.sh new file mode 100755 index 0000000..335b9ac --- /dev/null +++ b/start_satellite.sh @@ -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[@]}" + + diff --git a/test.wav b/test.wav new file mode 100755 index 0000000..5ba32cf Binary files /dev/null and b/test.wav differ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..fbab3dc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Wyoming satellite tests.""" diff --git a/tests/shared.py b/tests/shared.py new file mode 100755 index 0000000..2b47c37 --- /dev/null +++ b/tests/shared.py @@ -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 diff --git a/tests/test_satellite.py b/tests/test_satellite.py new file mode 100755 index 0000000..a6686ab --- /dev/null +++ b/tests/test_satellite.py @@ -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 diff --git a/tests/test_wake_streaming.py b/tests/test_wake_streaming.py new file mode 100755 index 0000000..2550ee5 --- /dev/null +++ b/tests/test_wake_streaming.py @@ -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 diff --git a/tox.ini b/tox.ini new file mode 100755 index 0000000..7f338b7 --- /dev/null +++ b/tox.ini @@ -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} diff --git a/wyoming-satellite.service b/wyoming-satellite.service new file mode 100755 index 0000000..630cd81 --- /dev/null +++ b/wyoming-satellite.service @@ -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 \ No newline at end of file diff --git a/wyoming_satellite.zip b/wyoming_satellite.zip new file mode 100755 index 0000000..4e60663 Binary files /dev/null and b/wyoming_satellite.zip differ diff --git a/wyoming_satellite/__init__.py b/wyoming_satellite/__init__.py new file mode 100755 index 0000000..2ca5bb5 --- /dev/null +++ b/wyoming_satellite/__init__.py @@ -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", +] diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py new file mode 100755 index 0000000..edc3e90 --- /dev/null +++ b/wyoming_satellite/__main__.py @@ -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() diff --git a/wyoming_satellite/event_handler.py b/wyoming_satellite/event_handler.py new file mode 100755 index 0000000..58f046b --- /dev/null +++ b/wyoming_satellite/event_handler.py @@ -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() diff --git a/wyoming_satellite/example_event_client.py b/wyoming_satellite/example_event_client.py new file mode 100755 index 0000000..606236e --- /dev/null +++ b/wyoming_satellite/example_event_client.py @@ -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 diff --git a/wyoming_satellite/satellite.7z b/wyoming_satellite/satellite.7z new file mode 100755 index 0000000..0ebe7bd Binary files /dev/null and b/wyoming_satellite/satellite.7z differ diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py new file mode 100755 index 0000000..2793679 --- /dev/null +++ b/wyoming_satellite/satellite.py @@ -0,0 +1,1512 @@ +"""Satellite code.""" + +import array +import asyncio +import logging +import math +import time +import wave +import subprocess +import re +import psutil +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path +from typing import Callable, Dict, Final, List, Optional, Set, Union + +from pyring_buffer import RingBuffer +from wyoming.asr import Transcript +from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop +from wyoming.client import AsyncClient +from wyoming.error import Error +from wyoming.event import Event, async_write_event +from wyoming.info import Describe, Info +from wyoming.mic import MicProcessAsyncClient +from wyoming.ping import Ping, Pong +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import ( + PauseSatellite, + RunSatellite, + SatelliteConnected, + SatelliteDisconnected, + StreamingStarted, + StreamingStopped, +) +from wyoming.snd import Played, SndProcessAsyncClient +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection, WakeProcessAsyncClient + +from .settings import SatelliteSettings +from .utils import ( + DebugAudioWriter, + multiply_volume, + normalize_wake_word, + run_event_command, + wav_to_events, +) +from .vad import SileroVad, WebRtcVadEnergy +from .webrtc import WebRtcAudio + +_LOGGER = logging.getLogger() + +_PONG_TIMEOUT: Final = 5 +_PING_SEND_DELAY: Final = 2 +_WAKE_INFO_TIMEOUT: Final = 2 + + +class State(Enum): + NOT_STARTED = auto() + STARTING = auto() + STARTED = auto() + RESTARTING = auto() + STOPPING = auto() + STOPPED = auto() + + +@dataclass +class SoundEvent: + event: Event + is_tts: bool + + +# ----------------------------------------------------------------------------- + + +class SatelliteBase: + """Base class for satellites.""" + + def __init__(self, settings: SatelliteSettings) -> None: + self.settings = settings + self.server_id: Optional[str] = None + self._state = State.NOT_STARTED + self._state_changed = asyncio.Event() + self._writer: Optional[asyncio.StreamWriter] = None + + self._mic_task: Optional[asyncio.Task] = None + self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None + self._snd_task: Optional[asyncio.Task] = None + self._snd_queue: "Optional[asyncio.Queue[SoundEvent]]" = None + self._wake_task: Optional[asyncio.Task] = None + self._wake_queue: "Optional[asyncio.Queue[Event]]" = None + self._event_task: Optional[asyncio.Task] = None + self._event_queue: "Optional[asyncio.Queue[Event]]" = None + + self._ping_server_enabled: bool = False + self._pong_received_event = asyncio.Event() + self._ping_server_task: Optional[asyncio.Task] = None + + self.microphone_muted = False + self._unmute_microphone_task: Optional[asyncio.Task] = None + + # Debug audio recording + self.wake_audio_writer: Optional[DebugAudioWriter] = None + self.stt_audio_writer: Optional[DebugAudioWriter] = None + if settings.debug_recording_dir: + self.wake_audio_writer = DebugAudioWriter( + settings.debug_recording_dir, + "wake", + ring_buffer_size=(2 * 16000 * 2 * 1), # last 2 sec + ) + self.stt_audio_writer = DebugAudioWriter( + settings.debug_recording_dir, "stt" + ) + + @property + def is_running(self) -> bool: + """True if not stopping/stopped.""" + return self._state not in (State.STOPPED,) + + @property + def state(self) -> State: + """Get current state.""" + return self._state + + @state.setter + def state(self, value: State) -> None: + """Set state.""" + self._state = value + self._state_changed.set() + self._state_changed.clear() + + async def run(self) -> None: + """Run main satellite loop.""" + + while self.is_running: + try: + if self.state == State.NOT_STARTED: + await self._start() + elif self.state == State.RESTARTING: + await self._restart() + elif self.state == State.STOPPING: + await self._stop() + elif self.state == State.STOPPED: + await self.stopped() + break + else: + await self._state_changed.wait() + except Exception: + if self.is_running: + # Automatically restart + _LOGGER.exception("Unexpected error running satellite") + self.state = State.RESTARTING + + async def stop(self) -> None: + """Stop the satellite""" + self.state = State.STOPPING + + # Wait for STOPPED + while self.state != State.STOPPED: + await self._state_changed.wait() + + async def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None: + """Set event writer.""" + self.server_id = server_id + self._writer = writer + _LOGGER.debug("Server set: %s", server_id) + await self.trigger_server_connected() + + async def clear_server(self) -> None: + """Remove writer.""" + self.server_id = None + self._writer = None + self._disable_ping() + + _LOGGER.debug("Server disconnected") + await self.trigger_server_disonnected() + + async def event_to_server(self, event: Event) -> None: + """Send an event to the server.""" + if self._writer is None: + return + + try: + await async_write_event(event, self._writer) + except Exception as err: + await self.clear_server() + + if isinstance(err, ConnectionResetError): + _LOGGER.warning("Server disconnected unexpectedly") + else: + _LOGGER.exception("Unexpected error sending event to server") + + def _enable_ping(self) -> None: + self._ping_server_enabled = True + self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping") + + def _disable_ping(self) -> None: + self._ping_server_enabled = False + if self._ping_server_task is not None: + self._ping_server_task.cancel() + self._ping_server_task = None + + async def _ping_server(self) -> None: + try: + while self.is_running: + await asyncio.sleep(_PING_SEND_DELAY) + if (self.server_id is None) or (not self._ping_server_enabled): + # No server connected + continue + + # Send ping and wait for pong + self._pong_received_event.clear() + await self.event_to_server(Ping().event()) + try: + await asyncio.wait_for( + self._pong_received_event.wait(), timeout=_PONG_TIMEOUT + ) + except asyncio.TimeoutError: + if self.server_id is None: + # No server connected + continue + + _LOGGER.warning("Did not receive ping response within timeout") + await self.clear_server() + except asyncio.CancelledError: + pass + except Exception: + _LOGGER.exception("Unexpected error in ping server task") + + # ------------------------------------------------------------------------- + + async def _start(self) -> None: + """Connect to services.""" + self.state = State.STARTING + await self._connect_to_services() + self.state = State.STARTED + await self.started() + + async def started(self) -> None: + """Called when satellite has started.""" + + async def _stop(self) -> None: + """Disconnect from services.""" + self.server_id = None + self._writer = None + + await self._disconnect_from_services() + self._disable_ping() + self.state = State.STOPPED + + async def stopped(self) -> None: + """Called when satellite has stopped.""" + + async def event_from_server(self, event: Event) -> None: + """Called when an event is received from the server.""" + forward_event = True + + if Ping.is_type(event.type): + # Respond with pong + ping = Ping.from_event(event) + await self.event_to_server(Pong(text=ping.text).event()) + + if not self._ping_server_enabled: + # Enable pinging + self._enable_ping() + _LOGGER.debug("Ping enabled") + + forward_event = False + elif Pong.is_type(event.type): + # Response from our ping + self._pong_received_event.set() + forward_event = False + elif AudioChunk.is_type(event.type): + # TTS audio + await self.event_to_snd(event) + forward_event = False + elif AudioStart.is_type(event.type): + # TTS started + await self.event_to_snd(event) + await self.trigger_tts_start() + elif AudioStop.is_type(event.type): + # TTS stopped + await self.event_to_snd(event) + await self.trigger_tts_stop() + elif Detect.is_type(event.type): + # Wake word detection started + await self.trigger_detect() + elif Detection.is_type(event.type): + # Wake word detected + _LOGGER.debug("Wake word detected") + await self.trigger_detection(Detection.from_event(event)) + elif VoiceStarted.is_type(event.type): + # STT start + await self.trigger_stt_start() + elif VoiceStopped.is_type(event.type): + # STT stop + await self.trigger_stt_stop() + elif Transcript.is_type(event.type): + # STT text + _LOGGER.debug(event) + await self.trigger_transcript(Transcript.from_event(event)) + elif Synthesize.is_type(event.type): + # TTS request + _LOGGER.debug(event) + await self.trigger_synthesize(Synthesize.from_event(event)) + elif Error.is_type(event.type): + _LOGGER.warning(event) + await self.trigger_error(Error.from_event(event)) + elif TimerStarted.is_type(event.type): + _LOGGER.debug(event) + await self.trigger_timer_started(TimerStarted.from_event(event)) + elif TimerUpdated.is_type(event.type): + _LOGGER.debug(event) + await self.trigger_timer_updated(TimerUpdated.from_event(event)) + elif TimerCancelled.is_type(event.type): + _LOGGER.debug(event) + await self.trigger_timer_cancelled(TimerCancelled.from_event(event)) + elif TimerFinished.is_type(event.type): + _LOGGER.debug(event) + await self.trigger_timer_finished(TimerFinished.from_event(event)) + + # Forward everything except audio/ping/pong to event service + if forward_event: + await self.forward_event(event) + + async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None: + """Sends a RunPipeline event with the correct stages.""" + if self.settings.wake.enabled: + # Local wake word detection + start_stage = PipelineStage.ASR + restart_on_end = False + else: + # Remote wake word detection + start_stage = PipelineStage.WAKE + restart_on_end = not self.settings.vad.enabled + + if self.settings.snd.enabled: + # Play TTS response + end_stage = PipelineStage.TTS + else: + # No audio output + end_stage = PipelineStage.HANDLE + + run_pipeline = RunPipeline( + start_stage=start_stage, + end_stage=end_stage, + name=pipeline_name, + restart_on_end=restart_on_end, + snd_format=AudioFormat( + rate=self.settings.snd.rate, + width=self.settings.snd.width, + channels=self.settings.snd.channels, + ), + ).event() + _LOGGER.debug(run_pipeline) + await self.event_to_server(run_pipeline) + await self.forward_event(run_pipeline) + + async def _restart(self) -> None: + """Disconnects from services and restarts loop.""" + self.state = State.RESTARTING + await self._disconnect_from_services() + + _LOGGER.debug("Restarting in %s second(s)", self.settings.restart_timeout) + await asyncio.sleep(self.settings.restart_timeout) + self.state = State.NOT_STARTED + + async def _connect_to_services(self) -> None: + """Connects to configured services.""" + if self.settings.mic.enabled: + _LOGGER.debug( + "Connecting to mic service: %s", + self.settings.mic.uri or self.settings.mic.command, + ) + self._mic_task = asyncio.create_task(self._mic_task_proc(), name="mic") + + if self.settings.snd.enabled: + _LOGGER.debug( + "Connecting to snd service: %s", + self.settings.snd.uri or self.settings.snd.command, + ) + self._snd_task = asyncio.create_task(self._snd_task_proc(), name="snd") + + if self.settings.wake.enabled: + _LOGGER.debug( + "Connecting to wake service: %s", + self.settings.wake.uri or self.settings.wake.command, + ) + self._wake_task = asyncio.create_task(self._wake_task_proc(), name="wake") + + if self.settings.event.enabled: + _LOGGER.debug( + "Connecting to event service: %s", + self.settings.event.uri or self.settings.event.command, + ) + self._event_task = asyncio.create_task( + self._event_task_proc(), name="event" + ) + + _LOGGER.info("Connected to services") + + async def _disconnect_from_services(self) -> None: + """Disconnects from running services.""" + if self._mic_task is not None: + _LOGGER.debug("Stopping microphone service") + self._mic_task.cancel() + self._mic_task = None + + if self._snd_task is not None: + _LOGGER.debug("Stopping sound service") + self._snd_task.cancel() + self._snd_task = None + + if self._wake_task is not None: + _LOGGER.debug("Stopping wake service") + self._wake_task.cancel() + self._wake_task = None + + if self._event_task is not None: + _LOGGER.debug("Stopping event service") + self._event_task.cancel() + self._event_task = None + + _LOGGER.debug("Disconnected from services") + + # ------------------------------------------------------------------------- + # Microphone + # ------------------------------------------------------------------------- + + async def event_from_mic( + self, event: Event, audio_bytes: Optional[bytes] = None + ) -> None: + """Called when an event is received from the mic service. + + For AudioChunk events, the audio_bytes may be set if the audio was + already unpacked for preprocessing. Use this to avoid unpacking the + event again. + """ + + def _make_mic_client(self) -> Optional[AsyncClient]: + """Create client for mic service.""" + if self.settings.mic.command: + program, *program_args = self.settings.mic.command + return MicProcessAsyncClient( + rate=self.settings.mic.rate, + width=self.settings.mic.width, + channels=self.settings.mic.channels, + samples_per_chunk=self.settings.mic.samples_per_chunk, + program=program, + program_args=program_args, + ) + + if self.settings.mic.uri: + return AsyncClient.from_uri(self.settings.mic.uri) + + return None + + async def _mic_task_proc(self) -> None: + """Mic service loop.""" + mic_client: Optional[AsyncClient] = None + audio_bytes: Optional[bytes] = None + + if self.settings.mic.needs_webrtc and (self._mic_webrtc is None): + _LOGGER.debug("Using webrtc audio enhancements") + self._mic_webrtc = WebRtcAudio( + self.settings.mic.auto_gain, self.settings.mic.noise_suppression + ) + + async def _disconnect() -> None: + try: + if mic_client is not None: + await mic_client.disconnect() + except Exception: + pass # ignore disconnect errors + + while self.is_running: + try: + if mic_client is None: + mic_client = self._make_mic_client() + assert mic_client is not None + await mic_client.connect() + _LOGGER.debug("Connected to mic service") + + event = await mic_client.read_event() + if event is None: + _LOGGER.warning("Mic service disconnected") + await _disconnect() + mic_client = None # reconnect + await asyncio.sleep(self.settings.mic.reconnect_seconds) + continue + + # Audio processing + if ( + self.settings.mic.needs_processing + or (self.settings.mic.channel_index is not None) + and AudioChunk.is_type(event.type) + ): + chunk = AudioChunk.from_event(event) + if self.settings.mic.channel_index is not None: + if chunk.width != 2: + raise ValueError( + "Mic channel index selection requires 16-bit samples" + ) + + # Convert to unsigned 16-bit array to make channel extraction easier + audio_array = array.array("H", chunk.audio) + audio_bytes = audio_array[ + self.settings.mic.channel_index :: chunk.channels + ].tobytes() + + if self.settings.mic.needs_processing: + if audio_bytes is None: + audio_bytes = chunk.audio + + audio_bytes = self._process_mic_audio(chunk.audio) + + event = AudioChunk( + rate=chunk.rate, + width=chunk.width, + channels=( + chunk.channels + if (self.settings.mic.channel_index is None) + else 1 + ), + audio=audio_bytes, + ).event() + else: + audio_bytes = None + + await self.event_from_mic(event, audio_bytes) + except asyncio.CancelledError: + break + except Exception: + _LOGGER.exception("Unexpected error in mic read task") + await _disconnect() + mic_client = None # reconnect + await asyncio.sleep(self.settings.mic.reconnect_seconds) + + await _disconnect() + + def _process_mic_audio(self, audio_bytes: bytes) -> bytes: + """Perform audio pre-processing on mic input.""" + if self.settings.mic.volume_multiplier != 1.0: + audio_bytes = multiply_volume( + audio_bytes, self.settings.mic.volume_multiplier + ) + + if self._mic_webrtc is not None: + # Noise suppression and auto gain + audio_bytes = self._mic_webrtc(audio_bytes) + + return audio_bytes + + # ------------------------------------------------------------------------- + # Sound + # ------------------------------------------------------------------------- + + async def event_to_snd(self, event: Event, is_tts: bool = True) -> None: + """Send an event to the sound service.""" + if self._snd_queue is not None: + self._snd_queue.put_nowait(SoundEvent(event, is_tts)) + + def _make_snd_client(self) -> Optional[AsyncClient]: + """Create client for snd service.""" + if self.settings.snd.command: + program, *program_args = self.settings.snd.command + return SndProcessAsyncClient( + rate=self.settings.snd.rate, + width=self.settings.snd.width, + channels=self.settings.snd.channels, + program=program, + program_args=program_args, + ) + + if self.settings.snd.uri: + return AsyncClient.from_uri(self.settings.snd.uri) + + return None + + async def _snd_task_proc(self) -> None: + """Snd service loop.""" + snd_client: Optional[AsyncClient] = None + + async def _disconnect() -> None: + try: + if snd_client is not None: + await snd_client.disconnect() + except Exception: + pass # ignore disconnect errors + + while self.is_running: + try: + if self._snd_queue is None: + self._snd_queue = asyncio.Queue() + + snd_event = await self._snd_queue.get() + event = snd_event.event + + if snd_client is None: + snd_client = self._make_snd_client() + assert snd_client is not None + await snd_client.connect() + _LOGGER.debug("Connected to snd service") + + # Audio processing + if self.settings.snd.needs_processing and AudioChunk.is_type( + event.type + ): + chunk = AudioChunk.from_event(event) + audio_bytes = self._process_snd_audio(chunk.audio) + event = AudioChunk( + rate=chunk.rate, + width=chunk.width, + channels=chunk.channels, + audio=audio_bytes, + ).event() + + await snd_client.write_event(event) + + if self.settings.snd.disconnect_after_stop and AudioStop.is_type( + event.type + ): + await _disconnect() + if snd_event.is_tts: + await self.event_to_server(Played().event()) + await self.trigger_played() + snd_client = None # reconnect on next event + except asyncio.CancelledError: + break + except Exception: + _LOGGER.exception("Unexpected error in snd read task") + await _disconnect() + snd_client = None # reconnect + self._snd_queue = None + await asyncio.sleep(self.settings.snd.reconnect_seconds) + + await _disconnect() + + def _process_snd_audio(self, audio_bytes: bytes) -> bytes: + """Perform audio pre-processing on snd output.""" + if self.settings.snd.volume_multiplier != 1.0: + audio_bytes = multiply_volume( + audio_bytes, self.settings.snd.volume_multiplier + ) + + return audio_bytes + + async def _play_wav( + self, wav_path: Optional[Union[str, Path]], mute_microphone: bool = False + ) -> None: + """Send WAV as events to sound service.""" + if (not wav_path) or (not self.settings.snd.enabled): + return + + try: + if mute_microphone: + with wave.open(str(wav_path), "rb") as wav_file: + seconds_to_mute = wav_file.getnframes() / wav_file.getframerate() + + seconds_to_mute += self.settings.mic.seconds_to_mute_after_awake_wav + _LOGGER.debug("Muting microphone for %s second(s)", seconds_to_mute) + self.microphone_muted = True + self._unmute_microphone_task = asyncio.create_task( + self._unmute_microphone_after(seconds_to_mute) + ) + + for event in wav_to_events( + wav_path, + samples_per_chunk=self.settings.snd.samples_per_chunk, + ): + await self.event_to_snd(event, is_tts=False) + except Exception: + # Unmute in case of an error + self.microphone_muted = False + + raise + + async def _unmute_microphone_after(self, seconds: float) -> None: + await asyncio.sleep(seconds) + self.microphone_muted = False + _LOGGER.debug("Unmuted microphone") + + # ------------------------------------------------------------------------- + # Wake + # ------------------------------------------------------------------------- + + async def event_from_wake(self, event: Event) -> None: + """Called when an event is received from the wake service.""" + + async def event_to_wake(self, event: Event) -> None: + """Send event to the wake service.""" + if self._wake_queue is not None: + self._wake_queue.put_nowait(event) + + def _make_wake_client(self) -> Optional[AsyncClient]: + """Create client for wake service.""" + if self.settings.wake.command: + program, *program_args = self.settings.wake.command + return WakeProcessAsyncClient( + rate=self.settings.wake.rate, + width=self.settings.wake.width, + channels=self.settings.wake.channels, + program=program, + program_args=program_args, + ) + + if self.settings.wake.uri: + return AsyncClient.from_uri(self.settings.wake.uri) + + return None + + async def _wake_task_proc(self) -> None: + """Wake service loop.""" + wake_client: Optional[AsyncClient] = None + to_client_task: Optional[asyncio.Task] = None + from_client_task: Optional[asyncio.Task] = None + pending: Set[asyncio.Task] = set() + + async def _disconnect() -> None: + nonlocal to_client_task, from_client_task + try: + if wake_client is not None: + await wake_client.disconnect() + + # Clean up tasks + if to_client_task is not None: + to_client_task.cancel() + to_client_task = None + + if from_client_task is not None: + from_client_task.cancel() + from_client_task = None + except Exception: + pass # ignore disconnect errors + + while self.is_running: + try: + if self._wake_queue is None: + self._wake_queue = asyncio.Queue() + + if wake_client is None: + wake_client = self._make_wake_client() + assert wake_client is not None + await wake_client.connect() + _LOGGER.debug("Connected to wake service") + + # Reset + from_client_task = None + to_client_task = None + pending = set() + self._wake_queue = asyncio.Queue() + + # Inform wake service of which wake word(s) to detect + await self._send_wake_detect() + + # Read/write in "parallel" + if to_client_task is None: + # From satellite to wake service + to_client_task = asyncio.create_task( + self._wake_queue.get(), name="wake_to_client" + ) + pending.add(to_client_task) + + if from_client_task is None: + # From wake service to satellite + from_client_task = asyncio.create_task( + wake_client.read_event(), name="wake_from_client" + ) + pending.add(from_client_task) + + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + + if to_client_task in done: + # Event to go to wake service (audio) + assert to_client_task is not None + event = to_client_task.result() + to_client_task = None + await wake_client.write_event(event) + + if from_client_task in done: + # Event from wake service (detection) + assert from_client_task is not None + event = from_client_task.result() + from_client_task = None + + if event is None: + _LOGGER.warning("Wake service disconnected") + await _disconnect() + wake_client = None # reconnect + await asyncio.sleep(self.settings.wake.reconnect_seconds) + continue + + await self.event_from_wake(event) + + except asyncio.CancelledError: + break + except Exception: + _LOGGER.exception("Unexpected error in wake read task") + await _disconnect() + wake_client = None # reconnect + await asyncio.sleep(self.settings.wake.reconnect_seconds) + + await _disconnect() + + async def _send_wake_detect(self) -> None: + """Inform wake word service of which wake words to detect.""" + wake_names: Optional[List[str]] = None + if self.settings.wake.names: + wake_names = [w.name for w in self.settings.wake.names] + + await self.event_to_wake(Detect(names=wake_names).event()) + await self.event_to_wake( + AudioStart( + rate=self.settings.mic.rate, + width=self.settings.mic.width, + channels=self.settings.mic.channels, + ).event() + ) + await self.trigger_detect() + + # ------------------------------------------------------------------------- + # Events + # ------------------------------------------------------------------------- + + async def trigger_server_connected(self) -> None: + """Called when connected to server.""" + _LOGGER.info("Connected to server") + await run_event_command(self.settings.event.connected) + await self.forward_event(SatelliteConnected().event()) + + async def trigger_server_disonnected(self) -> None: + """Called when disconnected from server.""" + _LOGGER.info("Disconnected from server") + await run_event_command(self.settings.event.disconnected) + await self.forward_event(SatelliteDisconnected().event()) + + async def trigger_streaming_start(self) -> None: + """Called when audio streaming starts.""" + await run_event_command(self.settings.event.streaming_start) + await self.forward_event(StreamingStarted().event()) + + async def trigger_streaming_stop(self) -> None: + """Called when audio streaming stops.""" + await run_event_command(self.settings.event.streaming_stop) + await self.forward_event(StreamingStopped().event()) + + async def trigger_detect(self) -> None: + """Called when wake word detection starts.""" + await run_event_command(self.settings.event.detect) + + async def trigger_detection(self, detection: Detection) -> None: + """Called when wake word is detected.""" + await run_event_command(self.settings.event.detection, detection.name) + await self._play_wav( + self.settings.snd.awake_wav, + mute_microphone=self.settings.mic.mute_during_awake_wav, + ) + + async def trigger_played(self) -> None: + """Called when audio stopped playing""" + await run_event_command(self.settings.event.played) + await self.forward_event(Played().event()) + + async def trigger_transcript(self, transcript: Transcript) -> None: + """Called when speech-to-text text is received.""" + await run_event_command(self.settings.event.transcript, transcript.text) + await self._play_wav(self.settings.snd.done_wav) + + async def trigger_stt_start(self) -> None: + """Called when user starts speaking.""" + await run_event_command(self.settings.event.stt_start) + + async def trigger_stt_stop(self) -> None: + """Called when user stops speaking.""" + await run_event_command(self.settings.event.stt_stop) + + async def trigger_synthesize(self, synthesize: Synthesize) -> None: + """Called when text-to-speech text is received.""" + await run_event_command(self.settings.event.synthesize, synthesize.text) + + async def trigger_tts_start(self) -> None: + """Called when text-to-speech audio starts.""" + await run_event_command(self.settings.event.tts_start) + + async def trigger_tts_stop(self) -> None: + """Called when text-to-speech audio stops.""" + await run_event_command(self.settings.event.tts_stop) + + async def trigger_error(self, error: Error) -> None: + """Called when an error occurs on the server.""" + await run_event_command(self.settings.event.error, error.text) + + async def trigger_timer_started(self, timer_started: TimerStarted) -> None: + """Called when timer-started event is received.""" + await run_event_command(self.settings.timer.started, timer_started) + + async def trigger_timer_updated(self, timer_updated: TimerUpdated) -> None: + """Called when timer-updated event is received.""" + await run_event_command(self.settings.timer.updated, timer_updated) + + async def trigger_timer_cancelled(self, timer_cancelled: TimerCancelled) -> None: + """Called when timer-cancelled event is received.""" + await run_event_command(self.settings.timer.cancelled, timer_cancelled.id) + + async def trigger_timer_finished(self, timer_finished: TimerFinished) -> None: + """Called when timer-finished event is received.""" + await run_event_command(self.settings.timer.finished, timer_finished.id) + for _ in range(self.settings.timer.finished_wav_plays): + await self._play_wav( + self.settings.timer.finished_wav, + mute_microphone=self.settings.mic.mute_during_awake_wav, + ) + await asyncio.sleep(self.settings.timer.finished_wav_delay) + + async def forward_event(self, event: Event) -> None: + """Forward an event to the event service.""" + if self._event_queue is not None: + self._event_queue.put_nowait(event) + + def _make_event_client(self) -> Optional[AsyncClient]: + """Create client for event service.""" + if self.settings.event.uri: + return AsyncClient.from_uri(self.settings.event.uri) + + return None + + async def _event_task_proc(self) -> None: + """Event service loop.""" + event_client: Optional[AsyncClient] = None + + async def _disconnect() -> None: + try: + if event_client is not None: + await event_client.disconnect() + except Exception: + pass # ignore disconnect errors + + while self.is_running: + try: + if self._event_queue is None: + self._event_queue = asyncio.Queue() + + event = await self._event_queue.get() + + if event_client is None: + event_client = self._make_event_client() + assert event_client is not None + await event_client.connect() + _LOGGER.debug("Connected to event service") + + await event_client.write_event(event) + except asyncio.CancelledError: + break + except Exception: + _LOGGER.exception("Unexpected error in event read task") + await _disconnect() + event_client = None # reconnect + self._event_queue = None + await asyncio.sleep(self.settings.event.reconnect_seconds) + + await _disconnect() + + # ------------------------------------------------------------------------- + # Info + # ------------------------------------------------------------------------- + + async def update_info(self, info: Info) -> None: + pass + + +# ----------------------------------------------------------------------------- + + +class AlwaysStreamingSatellite(SatelliteBase): + """Satellite that always streams audio.""" + + def __init__(self, settings: SatelliteSettings) -> None: + super().__init__(settings) + self.is_streaming = False + + if settings.vad.enabled: + _LOGGER.warning("VAD is enabled but will not be used") + + if settings.wake.enabled: + _LOGGER.warning("Local wake word detection is enabled but will not be used") + + async def event_from_server(self, event: Event) -> None: + await super().event_from_server(event) + + if RunSatellite.is_type(event.type): + self.is_streaming = True + _LOGGER.info("Streaming audio") + await self._send_run_pipeline() + await self.trigger_streaming_start() + elif PauseSatellite.is_type(event.type): + self.is_streaming = False + _LOGGER.info("Satellite paused") + elif Detection.is_type(event.type): + # Start debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.start() + elif Transcript.is_type(event.type) or Error.is_type(event.type): + # Stop debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + if Transcript.is_type(event.type): + # We're always streaming + _LOGGER.info("Streaming audio") + + # Re-trigger streaming start even though we technically don't stop + # so the event service can reset LEDs, etc. + await self.trigger_streaming_start() + + async def event_from_mic( + self, event: Event, audio_bytes: Optional[bytes] = None + ) -> None: + if (not self.is_streaming) or self.microphone_muted: + return + + if AudioChunk.is_type(event.type): + # Forward to server + await self.event_to_server(event) + + # Debug audio recording + if self.stt_audio_writer is not None: + if audio_bytes is None: + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + self.stt_audio_writer.write(audio_bytes) + + +# ----------------------------------------------------------------------------- + + +class VadStreamingSatellite(SatelliteBase): + """Satellite that only streams after speech is detected.""" + + def __init__(self, settings: SatelliteSettings) -> None: + if not settings.vad.enabled: + raise ValueError("VAD is not enabled") + + super().__init__(settings) + self.is_streaming = False + if settings.vad.system == "silero": + self.vad = SileroVad( + threshold=settings.vad.threshold, + trigger_level=settings.vad.trigger_level, + ) + elif settings.vad.system == "webrtc": + self.vad = WebRtcVadEnergy( + trigger_level=settings.vad.trigger_level, + energy_threshold=settings.vad.energy_threshold, + ) + else: + raise ValueError(f"Unknown VAD system: {settings.vad.system}") + + # Timestamp in the future when we will have timed out (set with + # time.monotonic()) + self.timeout_seconds: Optional[float] = None + + # Audio from right before speech starts (circular buffer) + self.vad_buffer: Optional[RingBuffer] = None + + if settings.vad.buffer_seconds > 0: + # Assume 16Khz, 16-bit mono samples + vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2)) + self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes) + + if settings.wake.enabled: + _LOGGER.warning("Local wake word detection is enabled but will not be used") + + self._is_paused = False + + async def event_from_server(self, event: Event) -> None: + await super().event_from_server(event) + + if RunSatellite.is_type(event.type): + self._is_paused = False + _LOGGER.info("Waiting for speech") + elif Detection.is_type(event.type): + # Start debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.start() + elif ( + Transcript.is_type(event.type) + or Error.is_type(event.type) + or PauseSatellite.is_type(event.type) + ): + if PauseSatellite.is_type(event.type): + self._is_paused = True + _LOGGER.debug("Satellite paused") + + self.is_streaming = False + + # Stop debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + async def event_from_mic( + self, event: Event, audio_bytes: Optional[bytes] = None + ) -> None: + if ( + (not AudioChunk.is_type(event.type)) + or self.microphone_muted + or self._is_paused + ): + return + + # Only unpack chunk once + chunk: Optional[AudioChunk] = None + + # Debug audio recording + if self.stt_audio_writer is not None: + if audio_bytes is None: + # Need to unpack + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + self.stt_audio_writer.write(audio_bytes) + + if ( + self.is_streaming + and (self.timeout_seconds is not None) + and (time.monotonic() >= self.timeout_seconds) + ): + # Time out during wake word recognition + self.is_streaming = False + self.timeout_seconds = None + + # Stop debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + # Stop pipeline + await self.event_to_server(AudioStop().event()) + + _LOGGER.info("Waiting for speech") + await self.trigger_streaming_stop() + + if not self.is_streaming: + # Check VAD + if audio_bytes is None: + if chunk is None: + # Need to unpack + chunk = AudioChunk.from_event(event) + + audio_bytes = chunk.audio + + if not self.vad(audio_bytes): + # No speech + if self.vad_buffer is not None: + self.vad_buffer.put(audio_bytes) + + return + + # Speech detected + self.is_streaming = True + _LOGGER.info("Streaming audio") + await self._send_run_pipeline() + await self.trigger_streaming_start() + + if self.settings.vad.wake_word_timeout is not None: + # Set future time when we'll stop streaming if the wake word + # hasn't been detected. + self.timeout_seconds = ( + time.monotonic() + self.settings.vad.wake_word_timeout + ) + else: + # No timeout + self.timeout_seconds = None + + if self.vad_buffer is not None: + # Send contents of VAD buffer first. This is the audio that was + # recorded right before speech was detected. + if chunk is None: + chunk = AudioChunk.from_event(event) + + await self.event_to_server( + AudioChunk( + rate=chunk.rate, + width=chunk.width, + channels=chunk.channels, + audio=self.vad_buffer.getvalue(), + ).event() + ) + + self._reset_vad() + + if self.is_streaming: + # Forward to server + await self.event_to_server(event) + + def _reset_vad(self): + """Reset state of VAD.""" + self.vad(None) + + if self.vad_buffer is not None: + # Clear buffer + self.vad_buffer.put(bytes(self.vad_buffer.maxlen)) + + +# ----------------------------------------------------------------------------- + + +class WakeStreamingSatellite(SatelliteBase): + """Satellite that waits for local wake word detection before streaming.""" + + def __init__(self, settings: SatelliteSettings) -> None: + if not settings.wake.enabled: + raise ValueError("Local wake word detection is not enabled") + + super().__init__(settings) + self.is_streaming = False + + # Timestamp in the future when the refractory period is over (set with + # time.monotonic()). + # wake word id -> seconds + self.refractory_timestamp: Dict[Optional[str], float] = {} + + if settings.vad.enabled: + _LOGGER.warning("VAD is enabled but will not be used") + + # Used for debug audio recording so both wake and stt WAV files have the + # same timestamp. + self._debug_recording_timestamp: Optional[int] = None + + self._is_paused = False + + self._wake_info: Optional[Info] = None + self._wake_info_ready = asyncio.Event() + + def _kill_aplay_processes(self): + """Kill all aplay processes started by snd client.""" + for proc in psutil.process_iter(['pid', 'cmdline']): + try: + if proc.info['cmdline'] and 'aplay' in proc.info['cmdline']: + # Optionally check if it's our aplay (e.g., by args like -f cd -t raw) + if '-t' in proc.info['cmdline'] and 'raw' in proc.info['cmdline']: + proc.terminate() + _LOGGER.debug("Terminated aplay PID %d", proc.info['pid']) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + + async def event_from_server(self, event: Event) -> None: + # Only check event types once + is_run_satellite = False + is_pause_satellite = False + is_transcript = False + is_error = False + + if RunSatellite.is_type(event.type): + is_run_satellite = True + self._is_paused = False + + elif PauseSatellite.is_type(event.type): + is_pause_satellite = True + elif Transcript.is_type(event.type): + is_transcript = True + elif Error.is_type(event.type): + is_error = True + + if is_transcript or is_pause_satellite: + # Stop streaming before event_from_server is called because it will + # play the "done" WAV. + self.is_streaming = False + + # Stop debug recording (stt) + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + await super().event_from_server(event) + + if is_run_satellite or is_transcript or is_error or is_pause_satellite: + # Stop streaming + self.is_streaming = False + + if is_pause_satellite: + self._is_paused = True + _LOGGER.debug("Satellite is paused") + else: + # Go back to wake word detection + await self.trigger_streaming_stop() + + # It's possible to be paused in the middle of streaming + if not self._is_paused: + await self._send_wake_detect() + _LOGGER.info("Waiting for wake word") + + # Start debug recording (wake) + self._debug_recording_timestamp = time.monotonic_ns() + if self.wake_audio_writer is not None: + self.wake_audio_writer.start( + timestamp=self._debug_recording_timestamp + ) + + async def trigger_server_disonnected(self) -> None: + await super().trigger_server_disonnected() + + self.is_streaming = False + + # Stop debug recording (stt) + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + await self.trigger_streaming_stop() + + async def event_from_mic( + self, event: Event, audio_bytes: Optional[bytes] = None + ) -> None: + if ( + (not AudioChunk.is_type(event.type)) + or self.microphone_muted + or self._is_paused + ): + return + + # Debug audio recording + if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None): + if audio_bytes is None: + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + + if self.wake_audio_writer is not None: + self.wake_audio_writer.write(audio_bytes) + + if self.stt_audio_writer is not None: + self.stt_audio_writer.write(audio_bytes) + + if self.is_streaming: + # Forward to server + await self.event_to_server(event) + else: + # Forward to wake word service + await self.event_to_wake(event) + + + async def _get_current_volume_percent(self) -> int: + """Для seeed-2mic-voicecard — используем 'Headphone'""" + try: + result = subprocess.run( + ["amixer", "-c", "2", "-M", "sget", "Headphone"], + capture_output=True, text=True, check=True + ) + output = result.stdout + + # Ищем [75%] + match = re.search(r"\[(\d+)%\]", output) + if match: + volume = int(match.group(1)) + _LOGGER.info("Громкость динамика: %d%%", volume) + return volume + + # Если [off] + if "[off]" in output: + return 0 + + _LOGGER.warning("Громкость не распознана") + return 50 + + except Exception as e: + _LOGGER.error("Ошибка amixer: %s", e) + return 50 + async def _set_alsa_volume(self, percent: int): + try: + subprocess.run([ + "amixer", "-c", "2", "-M", "sset", "Headphone", f"{percent}%" + ], check=True) + _LOGGER.info("Громкость установлена: %d%%", percent) + await self._play_wav("sounds/volume.wav") + except Exception as e: + _LOGGER.error("Не удалось установить: %s", e) + async def _adjust_volume(self, increase: bool): + """Громче (+10%) или тише (-10%)""" + current = await self._get_current_volume_percent() + step = 10 + new_volume = current + step if increase else current - step + new_volume = max(0, min(100, new_volume)) # 0–100% + + await self._set_alsa_volume(new_volume) + + + + async def _say_volume(self, percent: int): + text = f"Текущая громкость {percent} процентов" + _LOGGER.info("ГОВОРЮ: %s", text) + + # 1. Запускаем пайплайн с TTS + run_pipeline = RunPipeline( + start_stage=PipelineStage.TTS, + end_stage=PipelineStage.TTS + ) + await self.event_to_server(run_pipeline.event()) + _LOGGER.info("Пайплайн TTS запущен") + + # 2. Отправляем текст для TTS + synthesize = Synthesize(text=text) + await self.event_to_server(synthesize.event()) + _LOGGER.info("Текст для TTS отправлен: %s", text) + async def event_from_wake(self, event: Event) -> None: + if Info.is_type(event.type): + self._wake_info = Info.from_event(event) + _LOGGER.debug("Received Info from wake service") + self._wake_info_ready.set() + return + + if self.is_streaming or (self.server_id is None): + return + + if Detection.is_type(event.type): + detection = Detection.from_event(event) + text = detection.name.strip().lower() + + # --- КОМАНДЫ: громкость, остановка --- + # --- КОМАНДА: КАКАЯ ГРОМКОСТЬ? --- + if text == "какая громкость": + _LOGGER.info("Команда 'какая громкость' получена") + current_volume = await self._get_current_volume_percent() + await self._say_volume(current_volume) + return # ← не активируем STT + if text.startswith("дроид громкость "): + level_word = text.split()[-1] + levels = {"ноль":0, "один":10, "два":20, "три":30, "четыре":40, + "пять":50, "шесть":60, "семь":70, "восемь":80, + "девять":90, "десять":100} + if level_word in levels: + await self._set_alsa_volume(levels[level_word]) + _LOGGER.info("УСТАНОВИЛ ГРОМКОСТЬ: %d%%", levels[level_word]) + return + # --- КОМАНДА: ГРОМЧЕ, ТИШЕ --- + if text == "дроид сделай громче": + await self._adjust_volume(increase=True) + return + if text == "дроид сделай тише": + await self._adjust_volume(increase=False) + return + # --- КОМАНДА: ОСТАНОВИ, ХВАТИТ, СТОП, ОТМЕНЯЙ --- + if text in ["дроид останови", "дроид хватит", "дроид стоп", "дроид отмена"]: + _LOGGER.info("ОСТАНОВИЛ ПО КОМАНДЕ: %s", text) + self._kill_aplay_processes() + # Опционально: отправить Played(), чтобы сервер знал, что TTS прерван + await self.event_to_server(Played().event()) + return + + # --- ПРОВЕРКА: это wake word? --- + wake_words = [normalize_wake_word(w.name) for w in (self.settings.wake.names or [])] + if text not in wake_words: + _LOGGER.debug("Игнорируем (не wake word): %s", text) + return + + # --- ЭТО WAKE WORD → оригинальная логика --- + # (всё, что было в оригинале) + + refractory_timestamp = self.refractory_timestamp.get(detection.name) + if (refractory_timestamp is not None) and (refractory_timestamp > time.monotonic()): + _LOGGER.debug("Wake word detection occurred during refractory period") + return + + if self.wake_audio_writer is not None: + self.wake_audio_writer.stop() + if self.stt_audio_writer is not None: + self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp) + + _LOGGER.debug(detection) + self.is_streaming = True + _LOGGER.info("Streaming audio (wake word: %s)", detection.name) + + if self.settings.wake.refractory_seconds is not None: + self.refractory_timestamp[detection.name] = ( + time.monotonic() + self.settings.wake.refractory_seconds + ) + else: + self.refractory_timestamp.pop(detection.name, None) + + await self.event_to_server(event) + + pipeline_name: Optional[str] = None + if self.settings.wake.names: + detection_name = normalize_wake_word(detection.name) + for wake_name in self.settings.wake.names: + if normalize_wake_word(wake_name.name) == detection_name: + pipeline_name = wake_name.pipeline + break + + await self._send_run_pipeline(pipeline_name=pipeline_name) + await self.forward_event(event) + await self.trigger_detection(detection) + await self.trigger_streaming_start() + async def update_info(self, info: Info) -> None: + self._wake_info = None + self._wake_info_ready.clear() + _LOGGER.debug("Sending Describe to wake service") + await self.event_to_wake(Describe().event()) + + try: + await asyncio.wait_for( + self._wake_info_ready.wait(), timeout=_WAKE_INFO_TIMEOUT + ) + + if self._wake_info is not None: + # Update wake info only + info.wake = self._wake_info.wake + _LOGGER.debug("Updated info with wake info") + else: + _LOGGER.warning("Wake info is None after timeout") + except asyncio.TimeoutError: + _LOGGER.warning("Failed to get info from wake service") diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py new file mode 100755 index 0000000..0e9d42b --- /dev/null +++ b/wyoming_satellite/settings.py @@ -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.""" diff --git a/wyoming_satellite/utils/__init__.py b/wyoming_satellite/utils/__init__.py new file mode 100755 index 0000000..e47c60b --- /dev/null +++ b/wyoming_satellite/utils/__init__.py @@ -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", +] diff --git a/wyoming_satellite/utils/audio.py b/wyoming_satellite/utils/audio.py new file mode 100755 index 0000000..fb74300 --- /dev/null +++ b/wyoming_satellite/utils/audio.py @@ -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 diff --git a/wyoming_satellite/utils/misc.py b/wyoming_satellite/utils/misc.py new file mode 100755 index 0000000..cd9eaac --- /dev/null +++ b/wyoming_satellite/utils/misc.py @@ -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 diff --git a/wyoming_satellite/vad.py b/wyoming_satellite/vad.py new file mode 100755 index 0000000..9caaf53 --- /dev/null +++ b/wyoming_satellite/vad.py @@ -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 diff --git a/wyoming_satellite/webrtc.py b/wyoming_satellite/webrtc.py new file mode 100755 index 0000000..03185c9 --- /dev/null +++ b/wyoming_satellite/webrtc.py @@ -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