Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1915d3fce3 | ||
|
|
a2bb7c8f57 | ||
|
|
cc9b33e8b8 | ||
|
|
862d102405 | ||
|
|
65c123ffb9 | ||
|
|
6d11883dcb | ||
|
|
2f4276e180 | ||
|
|
397d4c8b61 | ||
|
|
f6c0d29405 | ||
|
|
a80c7724a6 | ||
|
|
2e046c6b8b | ||
|
|
5d3c243a88 | ||
|
|
2b394504a0 | ||
|
|
c6d1dec918 | ||
|
|
2f6ec1d3b0 | ||
|
|
41f107548f | ||
|
|
2824b653c5 | ||
|
|
14edd814bb | ||
|
|
af18c7c0f4 | ||
|
|
16847ef5e9 | ||
|
|
9b35e0334f | ||
|
|
f0297415ed | ||
|
|
30e9d74c45 | ||
|
|
69ceeef566 | ||
|
|
962b2f5dc6 | ||
|
|
36353ff03a | ||
|
|
886019e04f | ||
|
|
70652bbea2 | ||
|
|
61ae4ce68c | ||
|
|
00188fa5da | ||
|
|
8a5f3fb409 | ||
|
|
f0dcf39946 | ||
|
|
42325ec284 | ||
|
|
1bc3471637 | ||
|
|
8850100ca6 | ||
|
|
8e793a185d | ||
|
|
12cce8e8f5 | ||
|
|
fbc129711d | ||
|
|
7a45d6b2e9 | ||
|
|
9e6195baa7 | ||
|
|
9fda18770b | ||
|
|
503dc952bb | ||
|
|
366547c457 | ||
|
|
74f22e649c | ||
|
|
865b40722b | ||
|
|
20bbd3cb4f | ||
|
|
038e9667d2 | ||
|
|
8b24216e4e | ||
|
|
87bfcf4bf7 | ||
|
|
090b2588b7 | ||
|
|
a638600348 | ||
|
|
d1578cb863 | ||
|
|
086f346119 | ||
|
|
3b2842d11b | ||
|
|
1bceb97177 | ||
|
|
71ab73ad73 | ||
|
|
160370be5a | ||
|
|
594f6f696d | ||
|
|
bd9de77a4c | ||
|
|
5088b590a6 | ||
|
|
3a91fc468a | ||
|
|
4d52ef387d | ||
|
|
35e16ae328 | ||
|
|
232b89666a | ||
|
|
40a8516554 | ||
|
|
d3277e06f1 | ||
|
|
d5740a197f | ||
|
|
f6fcf74c3a | ||
|
|
a44dc28cdf | ||
|
|
f198e28d80 | ||
|
|
00d2625571 | ||
|
|
73604f9a71 | ||
|
|
ebc686d9ad | ||
|
|
33e6d80786 | ||
|
|
db5918e04a | ||
|
|
49df03426f | ||
|
|
8063f5b773 | ||
|
|
316427948e | ||
|
|
79dee35659 | ||
|
|
03bd01ab1f | ||
|
|
d29d3d0613 | ||
|
|
693b8e0e19 | ||
|
|
dc304e21e4 | ||
|
|
f6e397a1b0 | ||
|
|
350eee6cb2 | ||
|
|
b164bafc9f | ||
|
|
f1c48862a2 | ||
|
|
2d9c7b28aa | ||
|
|
e7dcbf4383 | ||
|
|
172a85846c | ||
|
|
f74071666a | ||
|
|
da72689bed | ||
|
|
970742711d | ||
|
|
ebfd9fa085 | ||
|
|
ec46c3c6aa | ||
|
|
d6a6075376 | ||
|
|
71d7afdc51 | ||
|
|
76af77484e | ||
|
|
6a1b51e2b4 | ||
|
|
2595e603e8 | ||
|
|
3ca5b5fd7d | ||
|
|
59ca3df424 | ||
|
|
20296973bd | ||
|
|
a881399d23 | ||
|
|
51426559c8 | ||
|
|
ec192b0ee8 | ||
|
|
f3f97bb7f7 | ||
|
|
48043d06e9 | ||
|
|
95f2e58c3a | ||
|
|
44870ca149 | ||
|
|
9e2074ccf9 |
@@ -5,4 +5,7 @@
|
|||||||
!script/setup
|
!script/setup
|
||||||
!script/run
|
!script/run
|
||||||
!wyoming_satellite/*.py
|
!wyoming_satellite/*.py
|
||||||
|
!wyoming_satellite/utils
|
||||||
|
!wyoming_satellite/VERSION
|
||||||
!docker/run
|
!docker/run
|
||||||
|
!sounds/*.wav
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: test
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test_linux:
|
||||||
|
name: "test on linux"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: setup
|
||||||
|
run: |
|
||||||
|
script/setup --dev
|
||||||
|
- name: test
|
||||||
|
run: |
|
||||||
|
script/test
|
||||||
+1
-1
@@ -9,7 +9,7 @@ tmp/
|
|||||||
build
|
build
|
||||||
htmlcov
|
htmlcov
|
||||||
|
|
||||||
/.venv/
|
.venv/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 1.0.0
|
||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY sounds/ ./sounds/
|
||||||
COPY script/setup ./script/
|
COPY script/setup ./script/
|
||||||
COPY setup.py requirements.txt MANIFEST.in ./
|
COPY setup.py requirements.txt MANIFEST.in ./
|
||||||
COPY wyoming_satellite/ ./wyoming_satellite/
|
COPY wyoming_satellite/ ./wyoming_satellite/
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
include requirements.txt
|
include requirements.txt
|
||||||
|
include VERSION
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
# Wyoming Satellite
|
# Wyoming Satellite
|
||||||
|
|
||||||
Remote satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
|
Remote voice satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
|
||||||
|
|
||||||
|
* Works with [Home Assistant](https://www.home-assistant.io/integrations/wyoming)
|
||||||
|
* Local wake word detection using [Wyoming services](https://github.com/rhasspy/wyoming#wyoming-projects)
|
||||||
|
* Audio enhancements using [webrtc](https://github.com/rhasspy/webrtc-noise-gain/)
|
||||||
|
|
||||||
|
See [the tutorial](docs/tutorial_2mic.md) to build a satellite using a [Raspberry Pi Zero 2 W](https://www.raspberrypi.com/products/raspberry-pi-zero-2-w/) and a [ReSpeaker 2Mic HAT](https://wiki.keyestudio.com/Ks0314_keyestudio_ReSpeaker_2-Mic_Pi_HAT_V1.0).
|
||||||
|
|
||||||
|
Video tutorials:
|
||||||
|
|
||||||
|
* [Wyoming Voice Satellite with ChatGPT](https://www.youtube.com/watch?v=eTKgc0YDCwE)
|
||||||
|
* [Local ChatGPT Voice Assistant](https://www.youtube.com/watch?v=pAKqKTkx5X4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
|
||||||
|
* Python 3.7+ (tested on 3.9+)
|
||||||
|
* A microphone
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
Install the necessary system dependencies:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo apt-get install python3-venv python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the install script:
|
||||||
|
|
||||||
``` sh
|
``` sh
|
||||||
script/setup
|
script/setup
|
||||||
```
|
```
|
||||||
@@ -94,20 +120,27 @@ script/run \
|
|||||||
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
|
||||||
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
|
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
|
||||||
--wake-uri 'tcp://127.0.0.1:10400' \
|
--wake-uri 'tcp://127.0.0.1:10400' \
|
||||||
--wake-word 'ok_nabu'
|
--wake-word-name 'ok_nabu'
|
||||||
```
|
```
|
||||||
|
|
||||||
Audio will only be streamed to the server after the wake word has been detected.
|
Audio will only be streamed to the server after the wake word has been detected.
|
||||||
|
|
||||||
|
Once a wake word has been detected, it can not be detected again for several seconds (called the "refractory period"). You can change this with `--wake-refractory-seconds <SECONDS>`.
|
||||||
|
|
||||||
|
Note that `--vad` is unnecessary when connecting to a local instance of openwakeword.
|
||||||
|
|
||||||
## Sounds
|
## Sounds
|
||||||
|
|
||||||
You can play a WAV file when the wake word is detected (locally or remotely), and when speech-to-text has completed:
|
You can play a WAV file when the wake word is detected (locally or remotely), and when speech-to-text has completed:
|
||||||
|
|
||||||
* `--awake-wav <WAV>` - played when the wake word is detected
|
* `--awake-wav <WAV>` - played when the wake word is detected
|
||||||
* `--done-wav <WAV>` - played when the voice command is finished
|
* `--done-wav <WAV>` - played when the voice command is finished
|
||||||
|
* `--timer-finished-wav <WAV>` - played when a timer is finished
|
||||||
|
|
||||||
If you want to play audio files other than WAV, use [event commands](#event-commands). Specifically, the `--detection-command` to replace `--awake-wav` and `--transcript-command` to replace `--done-wav`.
|
If you want to play audio files other than WAV, use [event commands](#event-commands). Specifically, the `--detection-command` to replace `--awake-wav` and `--transcript-command` to replace `--done-wav`.
|
||||||
|
|
||||||
|
The timer finished sound can be repeated with `--timer-finished-wav-repeat <repeats> <delay>` where `<repeats>` is the number of times to repeat the WAV, and `<delay>` is the number of seconds to wait between repeats.
|
||||||
|
|
||||||
## Audio Enhancements
|
## Audio Enhancements
|
||||||
|
|
||||||
Install the dependencies for webrtc:
|
Install the dependencies for webrtc:
|
||||||
@@ -125,7 +158,7 @@ script/run \
|
|||||||
--mic-noise-suppression 2
|
--mic-noise-suppression 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Automatic gain control is between 0-31, which 31 being the loudest.
|
Automatic gain control is between 0-31 dbFS, which 31 being the loudest.
|
||||||
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
|
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
|
||||||
|
|
||||||
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
|
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
|
||||||
@@ -143,8 +176,15 @@ Satellites can respond to events from the server by running commands:
|
|||||||
* `--stt-start-command` - user started speaking (no stdin)
|
* `--stt-start-command` - user started speaking (no stdin)
|
||||||
* `--stt-stop-command` - user stopped speaking (no stdin)
|
* `--stt-stop-command` - user stopped speaking (no stdin)
|
||||||
* `--synthesize-command` - text-to-speech text is returned (text on stdin)
|
* `--synthesize-command` - text-to-speech text is returned (text on stdin)
|
||||||
* `--tts-start-command` - text-to-speech response started (no stdin)
|
* `--tts-start-command` - text-to-speech response started streaming from server (no stdin)
|
||||||
* `--tts-stop-command` - text-to-speech response stopped (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)
|
* `--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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# 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/' \
|
||||||
|
-r requirements.txt \
|
||||||
|
-r requirements_audio_enhancement.txt \
|
||||||
|
-r requirements_vad.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
If the installation was successful, you should be able to run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
script/run --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Determine Audio Devices
|
||||||
|
|
||||||
|
Picking the correct microphone/speaker devices is critical for the satellite to work. We'll do a test recording and playback in this section.
|
||||||
|
|
||||||
|
List your available microphones with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
arecord -L
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have the ReSpeaker 2Mic HAT, you should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
plughw:CARD=seeed2micvoicec,DEV=0
|
||||||
|
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
|
||||||
|
Hardware device with all software conversions
|
||||||
|
```
|
||||||
|
|
||||||
|
For other microphones, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
|
||||||
|
|
||||||
|
Record a 5 second sample from your chosen microphone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t wav -d 5 test.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
Say something while `arecord` is running. If you get errors, try a different microphone device by changing `-D <device>`.
|
||||||
|
|
||||||
|
List your available speakers with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aplay -L
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have the ReSpeaker 2Mic HAT, you should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
plughw:CARD=seeed2micvoicec,DEV=0
|
||||||
|
seeed-2mic-voicecard, bcm2835-i2s-wm8960-hifi wm8960-hifi-0
|
||||||
|
Hardware device with all software conversions
|
||||||
|
```
|
||||||
|
|
||||||
|
For other speakers, prefer ones that start with `plughw:` or just use `default` if you don't know what to use.
|
||||||
|
|
||||||
|
Play back your recorded sample WAV:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aplay -D plughw:CARD=seeed2micvoicec,DEV=0 test.wav
|
||||||
|
```
|
||||||
|
|
||||||
|
You should hear your recorded sample. If there are problems, try a different speaker device by changing `-D <device>`.
|
||||||
|
|
||||||
|
Make note of your microphone and speaker devices for the next step.
|
||||||
|
|
||||||
|
## Running the Satellite
|
||||||
|
|
||||||
|
In the `wyoming-satellite` directory, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
script/run \
|
||||||
|
--debug \
|
||||||
|
--name 'my satellite' \
|
||||||
|
--uri 'tcp://0.0.0.0:10700' \
|
||||||
|
--mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' \
|
||||||
|
--snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `-D <device>` for `arecord` and `aplay` to match the audio devices from the previous section.
|
||||||
|
You can set `--name <NAME>` to whatever you want, but it should stay the same every time you run the satellite.
|
||||||
|
|
||||||
|
In Home Assistant, check the "Devices & services" section in Settings. After some time, you should see your satellite show up as "Discovered" (Wyoming Protocol). Click the "Configure" button and "Submit". Choose the area that your satellite is located, and click "Finish".
|
||||||
|
|
||||||
|
Your satellite should say "Streaming audio", and you can use the wake word of your preferred pipeline.
|
||||||
|
|
||||||
|
## Create Services
|
||||||
|
|
||||||
|
You can run wyoming-satellite as a systemd service by first creating a service file:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl edit --force --full wyoming-satellite.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Unit]
|
||||||
|
Description=Wyoming Satellite
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/home/pi/wyoming-satellite/script/run --name 'my satellite' --uri 'tcp://0.0.0.0:10700' --mic-command 'arecord -D plughw:CARD=seeed2micvoicec,DEV=0 -r 16000 -c 1 -f S16_LE -t raw' --snd-command 'aplay -D plughw:CARD=seeed2micvoicec,DEV=0 -r 22050 -c 1 -f S16_LE -t raw'
|
||||||
|
WorkingDirectory=/home/pi/wyoming-satellite
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the file and exit your editor. Next, enable the service to start at boot and run it:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl enable --now wyoming-satellite.service
|
||||||
|
```
|
||||||
|
|
||||||
|
(you may need to hit CTRL+C to get back to a shell prompt)
|
||||||
|
|
||||||
|
With the service running, you can view logs in real-time with:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
journalctl -u wyoming-satellite.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
If needed, disable and stop the service with:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl disable --now wyoming-satellite.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio Enhancements
|
||||||
|
|
||||||
|
You can run the satellite with automatic gain control and noise suppression:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
script/run \
|
||||||
|
... \
|
||||||
|
--mic-auto-gain 5 \
|
||||||
|
--mic-noise-suppression 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Automatic gain control is between 0-31 dbFS, which 31 being the loudest.
|
||||||
|
Noise suppression is from 0-4, with 4 being maximum suppression (may cause audio distortion).
|
||||||
|
|
||||||
|
You can also use `--mic-volume-multiplier X` to multiply all audio samples by `X`. For example, using 2 for `X` will double the microphone volume (but may cause audio distortion). The corresponding `--snd-volume-multiplier` does the same for audio playback.
|
||||||
|
|
||||||
|
## Local Wake Word Detection
|
||||||
|
|
||||||
|
Install the necessary system dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --no-install-recommends \
|
||||||
|
libopenblas-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
From your home directory, install the openWakeWord Wyoming service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/rhasspy/wyoming-openwakeword.git
|
||||||
|
cd wyoming-openwakeword
|
||||||
|
script/setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a systemd service for it:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl edit --force --full wyoming-openwakeword.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste in the following template, and change both `/home/pi` and the `script/run` arguments to match your set up:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Unit]
|
||||||
|
Description=Wyoming openWakeWord
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/home/pi/wyoming-openwakeword/script/run --uri 'tcp://127.0.0.1:10400'
|
||||||
|
WorkingDirectory=/home/pi/wyoming-openwakeword
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the file and exit your editor.
|
||||||
|
|
||||||
|
You can now update your satellite service:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl edit --force --full wyoming-satellite.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Update just the parts below:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Unit]
|
||||||
|
...
|
||||||
|
Requires=wyoming-openwakeword.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
...
|
||||||
|
ExecStart=/home/pi/wyoming-satellite/script/run ... --wake-uri 'tcp://127.0.0.1:10400' --wake-word-name 'ok_nabu'
|
||||||
|
...
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload and restart the satellite service:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart wyoming-satellite.service
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the wake service get automatically loaded:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
sudo systemctl status wyoming-satellite.service wyoming-openwakeword.service
|
||||||
|
```
|
||||||
|
|
||||||
|
They should all be "active (running)" and green.
|
||||||
|
|
||||||
|
Test out your satellite by saying "ok, nabu" and a voice command. Use `journalctl` to check the logs of services for errors:
|
||||||
|
|
||||||
|
``` sh
|
||||||
|
journalctl -u wyoming-openwakeword.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service.
|
||||||
|
|
||||||
|
## LED Service
|
||||||
|
|
||||||
|
Example event services for the ReSpeaker 2Mic and 4Mic HATs are included in `wyoming-satellite/examples` that will change the LED color depending on the satellite state. The example below is for the 2Mic HAT, using `2mic_service.py`. If you're using the 4Mic HAT, use `4mic_service.py` instead as the LEDs and GPIO pins are slightly different.
|
||||||
|
|
||||||
|
Install it from your home directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd wyoming-satellite/examples
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/pip3 install --upgrade pip
|
||||||
|
.venv/bin/pip3 install --upgrade wheel setuptools
|
||||||
|
.venv/bin/pip3 install 'wyoming==1.5.2'
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Executable
+79
@@ -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
|
||||||
@@ -12,7 +12,13 @@ import gpiozero
|
|||||||
import spidev
|
import spidev
|
||||||
from wyoming.asr import Transcript
|
from wyoming.asr import Transcript
|
||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
|
from wyoming.satellite import (
|
||||||
|
RunSatellite,
|
||||||
|
SatelliteConnected,
|
||||||
|
SatelliteDisconnected,
|
||||||
|
StreamingStarted,
|
||||||
|
StreamingStopped,
|
||||||
|
)
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
from wyoming.vad import VoiceStarted
|
from wyoming.vad import VoiceStarted
|
||||||
from wyoming.wake import Detection
|
from wyoming.wake import Detection
|
||||||
@@ -35,8 +41,14 @@ async def main() -> None:
|
|||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
#
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
@@ -48,7 +60,7 @@ async def main() -> None:
|
|||||||
led_power = gpiozero.LED(LEDS_GPIO, active_high=False)
|
led_power = gpiozero.LED(LEDS_GPIO, active_high=False)
|
||||||
led_power.on()
|
led_power.on()
|
||||||
|
|
||||||
leds = APA102(num_led=NUM_LEDS)
|
leds = APA102(num_led=NUM_LEDS, global_brightness=args.led_brightness)
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
server = AsyncServer.from_uri(args.uri)
|
server = AsyncServer.from_uri(args.uri)
|
||||||
@@ -107,6 +119,15 @@ class LEDsEventHandler(AsyncEventHandler):
|
|||||||
self.color(_BLACK)
|
self.color(_BLACK)
|
||||||
elif RunSatellite.is_type(event.type):
|
elif RunSatellite.is_type(event.type):
|
||||||
self.color(_BLACK)
|
self.color(_BLACK)
|
||||||
|
elif SatelliteConnected.is_type(event.type):
|
||||||
|
# Flash
|
||||||
|
for _ in range(3):
|
||||||
|
self.color(_GREEN)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
self.color(_BLACK)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
elif SatelliteDisconnected.is_type(event.type):
|
||||||
|
self.color(_RED)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -133,7 +154,7 @@ class APA102:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
num_led,
|
num_led,
|
||||||
global_brightness=MAX_BRIGHTNESS,
|
global_brightness,
|
||||||
order="rgb",
|
order="rgb",
|
||||||
bus=0,
|
bus=0,
|
||||||
device=1,
|
device=1,
|
||||||
@@ -147,6 +168,7 @@ class APA102:
|
|||||||
self.global_brightness = self.MAX_BRIGHTNESS
|
self.global_brightness = self.MAX_BRIGHTNESS
|
||||||
else:
|
else:
|
||||||
self.global_brightness = global_brightness
|
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.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
|
||||||
self.spi = spidev.SpiDev() # Init the SPI device
|
self.spi = spidev.SpiDev() # Init the SPI device
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/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 wyoming.event import Event
|
||||||
|
from wyoming.satellite import (
|
||||||
|
SatelliteConnected,
|
||||||
|
SatelliteDisconnected,
|
||||||
|
StreamingStarted,
|
||||||
|
StreamingStopped,
|
||||||
|
)
|
||||||
|
from wyoming.snd import Played
|
||||||
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
|
from pixel_ring import pixel_ring
|
||||||
|
|
||||||
|
_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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
websockets==12.0
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from wyoming.event import Event
|
||||||
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
|
parser.add_argument("--websocket-host", default="localhost")
|
||||||
|
parser.add_argument("--websocket-port", type=int, default=8675)
|
||||||
|
#
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
|
_LOGGER.info("Ready")
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
server = AsyncServer.from_uri(args.uri)
|
||||||
|
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.serve(
|
||||||
|
partial(websocket_connected, queue),
|
||||||
|
args.websocket_host,
|
||||||
|
args.websocket_port,
|
||||||
|
):
|
||||||
|
await server.run(partial(WebsocketEventHandler, args, queue))
|
||||||
|
finally:
|
||||||
|
queue.put_nowait(None)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
event = await queue.get()
|
||||||
|
if event is None:
|
||||||
|
# Stop signal
|
||||||
|
break
|
||||||
|
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error in websocket handler")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketEventHandler(AsyncEventHandler):
|
||||||
|
"""Event handler for clients."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cli_args: argparse.Namespace,
|
||||||
|
queue: "asyncio.Queue[Optional[Event]]",
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.cli_args = cli_args
|
||||||
|
self.queue = queue
|
||||||
|
|
||||||
|
async def handle_event(self, event: Event) -> bool:
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
self.queue.put_nowait(event)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
Binary file not shown.
@@ -0,0 +1,244 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="Wyoming satellite timer example">
|
||||||
|
<meta name="author" content="Michael Hansen">
|
||||||
|
|
||||||
|
<title>Wyoming Timer Example</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
display: flex;
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #03a9f4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
|
color: white;
|
||||||
|
margin-right: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
text-align: center;
|
||||||
|
width: 90px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paused {
|
||||||
|
background-color: yellow;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink-animation 1s linear infinite;
|
||||||
|
-webkit-animation: blink-animation 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink-animation {
|
||||||
|
0%, 100% {opacity: 1;}
|
||||||
|
50% {opacity: 0;}
|
||||||
|
}
|
||||||
|
@-webkit-keyframes blink-animation {
|
||||||
|
0%, 100% {opacity: 1;}
|
||||||
|
50% {opacity: 0;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="timers" class="timer-container">
|
||||||
|
</div>
|
||||||
|
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let socket = new WebSocket("ws://localhost:8675")
|
||||||
|
var timers = []
|
||||||
|
var finished_timers = 0
|
||||||
|
|
||||||
|
function q(selector) {return document.querySelector(selector)}
|
||||||
|
|
||||||
|
socket.onopen = function(e) {
|
||||||
|
console.log("Connected")
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = function(event) {
|
||||||
|
wyo_event = JSON.parse(event.data)
|
||||||
|
if (wyo_event.type == "timer-started") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
timer_info.is_active = true
|
||||||
|
console.log(timer_info)
|
||||||
|
|
||||||
|
timers.push(timer_info)
|
||||||
|
|
||||||
|
let timers_elem = q("#timers")
|
||||||
|
let timer_div = document.createElement("div")
|
||||||
|
timer_div.id = "timer-" + timer_info.id
|
||||||
|
timer_div.classList.add("timer")
|
||||||
|
|
||||||
|
let message_div = document.createElement("div")
|
||||||
|
message_div.classList.add("message")
|
||||||
|
|
||||||
|
if (timer_info.name) {
|
||||||
|
message_div.innerHTML = timer_info.name
|
||||||
|
} else {
|
||||||
|
if (timer_info.start_hours) {
|
||||||
|
message_div.innerHTML += timer_info.start_hours + " hr "
|
||||||
|
}
|
||||||
|
if (timer_info.start_minutes) {
|
||||||
|
message_div.innerHTML += timer_info.start_minutes + " min "
|
||||||
|
}
|
||||||
|
if (timer_info.start_seconds) {
|
||||||
|
message_div.innerHTML += timer_info.start_seconds + " sec "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer_div.appendChild(message_div)
|
||||||
|
|
||||||
|
let time_div = document.createElement("div")
|
||||||
|
time_div.classList.add("time")
|
||||||
|
|
||||||
|
let hours = timer_info.start_hours ?? 0
|
||||||
|
let minutes = timer_info.start_minutes ?? 0
|
||||||
|
let seconds = timer_info.start_seconds ?? 0
|
||||||
|
|
||||||
|
time_div.innerHTML =
|
||||||
|
hours.toString().padStart(2, 0) + ":" +
|
||||||
|
minutes.toString().padStart(2, 0) + ":" +
|
||||||
|
seconds.toString().padStart(2, 0);
|
||||||
|
|
||||||
|
timer_div.appendChild(time_div)
|
||||||
|
|
||||||
|
timers_elem.appendChild(timer_div)
|
||||||
|
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-cancelled") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
timers = timers.filter(t => t.id != timer_info.id)
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
timer_div.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-updated") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
for (i = 0; i < timers.length; i++) {
|
||||||
|
if (timers[i].id == timer_info.id) {
|
||||||
|
timers[i].is_active = timer_info.is_active
|
||||||
|
timers[i].total_seconds = timer_info.total_seconds
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
if (timer_info.is_active) {
|
||||||
|
timer_div.classList.remove("paused")
|
||||||
|
} else {
|
||||||
|
timer_div.classList.add("paused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-finished") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
for (i = 0; i < timers.length; i++) {
|
||||||
|
if (timers[i].id == timer_info.id) {
|
||||||
|
timers[i].total_seconds = 0
|
||||||
|
finished_timers += 1
|
||||||
|
if (finished_timers == 1) {
|
||||||
|
q("#audio-finished").play()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timers = timers.filter(t => t.id != timer_info.id)
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
timer_div.classList.add("finished")
|
||||||
|
timer_div.classList.add("blink")
|
||||||
|
|
||||||
|
timer_div.onclick = function() {
|
||||||
|
timer_div.remove()
|
||||||
|
finished_timers = Math.max(0, finished_timers - 1)
|
||||||
|
if (finished_timers == 0) {
|
||||||
|
let audio = q("#audio-finished")
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_div = q("#timer-" + timer_info.id + " .time")
|
||||||
|
if (time_div) {
|
||||||
|
time_div.style.opacity = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function(event) {
|
||||||
|
console.log("Disconnected")
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function(error) {
|
||||||
|
};
|
||||||
|
|
||||||
|
function update_timer(timer_info) {
|
||||||
|
if (timer_info.total_seconds < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_div = q("#timer-" + timer_info.id + " .time")
|
||||||
|
if (!time_div) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timer_info.is_active) {
|
||||||
|
// Paused
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer_info.total_seconds > 0) {
|
||||||
|
timer_info.total_seconds -= 1
|
||||||
|
}
|
||||||
|
let total_minutes = Math.floor(timer_info.total_seconds / 60)
|
||||||
|
|
||||||
|
let hours = Math.floor(total_minutes / 60)
|
||||||
|
let minutes = total_minutes % 60
|
||||||
|
let seconds = timer_info.total_seconds % 60
|
||||||
|
|
||||||
|
time_div.innerHTML =
|
||||||
|
hours.toString().padStart(2, 0) + ":" +
|
||||||
|
minutes.toString().padStart(2, 0) + ":" +
|
||||||
|
seconds.toString().padStart(2, 0)
|
||||||
|
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Command-line installer."""
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Command-line installer."""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .const import LOCAL_DIR, PROGRAM_DIR, SatelliteType, Settings
|
||||||
|
from .drivers import install_drivers
|
||||||
|
from .microphone import configure_microphone
|
||||||
|
from .packages import (
|
||||||
|
can_import,
|
||||||
|
install_packages,
|
||||||
|
install_packages_nogui,
|
||||||
|
packages_installed,
|
||||||
|
)
|
||||||
|
from .satellite import configure_satellite
|
||||||
|
from .services import generate_services, install_services, stop_services
|
||||||
|
from .speakers import configure_speakers
|
||||||
|
from .wake_word import configure_wake_word
|
||||||
|
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
logging.basicConfig(
|
||||||
|
filename=LOCAL_DIR / "installer.log", filemode="w", level=logging.DEBUG
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = Settings.load()
|
||||||
|
|
||||||
|
if not packages_installed("whiptail"):
|
||||||
|
install_packages_nogui("whiptail")
|
||||||
|
|
||||||
|
choice: Optional[str] = None
|
||||||
|
while True:
|
||||||
|
choice = main_menu(choice)
|
||||||
|
|
||||||
|
if choice == "satellite":
|
||||||
|
configure_satellite(settings)
|
||||||
|
elif choice == "microphone":
|
||||||
|
configure_microphone(settings)
|
||||||
|
elif choice == "speakers":
|
||||||
|
configure_speakers(settings)
|
||||||
|
elif choice == "wake":
|
||||||
|
configure_wake_word(settings)
|
||||||
|
elif choice == "drivers":
|
||||||
|
install_drivers(settings)
|
||||||
|
elif choice == "apply":
|
||||||
|
apply_settings(settings)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main_menu(last_choice: Optional[str]) -> Optional[str]:
|
||||||
|
items: List[ItemType] = [
|
||||||
|
("satellite", "Satellite"),
|
||||||
|
("microphone", "Microphone"),
|
||||||
|
("speakers", "Speakers"),
|
||||||
|
("wake", "Wake Word"),
|
||||||
|
("drivers", "Drivers"),
|
||||||
|
("apply", "Apply Settings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
return menu(
|
||||||
|
"Main",
|
||||||
|
items,
|
||||||
|
selected_item=last_choice,
|
||||||
|
menu_args=["--ok-button", "Select", "--cancel-button", "Exit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pip_install(*args) -> List[str]:
|
||||||
|
return [
|
||||||
|
str(PROGRAM_DIR / ".venv" / "bin" / "pip3"),
|
||||||
|
"install",
|
||||||
|
"--extra-index-url",
|
||||||
|
"https://www.piwheels.org/simple",
|
||||||
|
"-f",
|
||||||
|
"https://synesthesiam.github.io/prebuilt-apps/",
|
||||||
|
] + list(args)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_settings(settings: Settings) -> None:
|
||||||
|
if settings.mic.device is None:
|
||||||
|
msgbox("Please configure microphone")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (settings.satellite.type == SatelliteType.WAKE) and (
|
||||||
|
settings.wake.system is None
|
||||||
|
):
|
||||||
|
msgbox("Please set wake word system")
|
||||||
|
return
|
||||||
|
|
||||||
|
password: Optional[str] = None
|
||||||
|
if not packages_installed("python3-pip", "python3-venv"):
|
||||||
|
password = passwordbox("sudo password:")
|
||||||
|
if not password:
|
||||||
|
return
|
||||||
|
|
||||||
|
success = install_packages(
|
||||||
|
"Installing Python packages...", password, "python3-pip", "python3-venv"
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
error("installing pip/venv for Python")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Satellite venv
|
||||||
|
venv_dir = PROGRAM_DIR / ".venv"
|
||||||
|
if not venv_dir.exists():
|
||||||
|
result = run_with_gauge(
|
||||||
|
"Creating virtual environment...", [str(PROGRAM_DIR / "script" / "setup")]
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
error("creating virtual environment")
|
||||||
|
return
|
||||||
|
|
||||||
|
# silero (vad)
|
||||||
|
if (settings.satellite.type == SatelliteType.VAD) and (
|
||||||
|
not can_import("pysilero_vad")
|
||||||
|
):
|
||||||
|
result = run_with_gauge(
|
||||||
|
"Installing vad...",
|
||||||
|
[pip_install("-r", str(PROGRAM_DIR / "requirements_vad.txt"))],
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
error("installing vad")
|
||||||
|
return
|
||||||
|
|
||||||
|
# webrtc (audio enhancements)
|
||||||
|
if ((settings.mic.noise_suppression > 0) or (settings.mic.auto_gain > 0)) and (
|
||||||
|
not can_import("webrtc_noise_gain")
|
||||||
|
):
|
||||||
|
result = run_with_gauge(
|
||||||
|
"Installing audio enhancements...",
|
||||||
|
[
|
||||||
|
pip_install(
|
||||||
|
"-r", str(PROGRAM_DIR / "requirements_audio_enhancement.txt")
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
error("installing audio enhancements")
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.satellite.event_service_command
|
||||||
|
and (
|
||||||
|
("2mic" in settings.satellite.event_service_command)
|
||||||
|
or ("4mic" in settings.satellite.event_service_command)
|
||||||
|
)
|
||||||
|
) and (not can_import("gpiozero", "spidev")):
|
||||||
|
result = run_with_gauge(
|
||||||
|
"Installing event requirements...",
|
||||||
|
[pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))],
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
error("installing event requirements")
|
||||||
|
return
|
||||||
|
|
||||||
|
generate_services(settings)
|
||||||
|
|
||||||
|
if password is None:
|
||||||
|
password = passwordbox("sudo password:")
|
||||||
|
if not password:
|
||||||
|
return
|
||||||
|
|
||||||
|
stop_services(password)
|
||||||
|
install_services(settings, password)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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)
|
||||||
@@ -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__)
|
||||||
@@ -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
|
||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Python interface to whiptail command."""
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
|
||||||
|
|
||||||
|
ItemType = Union[str, Tuple[Any, str]]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def whiptail(*args) -> Optional[str]:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["whiptail", "--title", TITLE] + list(args),
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_stdout, stderr = proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return stderr.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def menu(
|
||||||
|
text: str,
|
||||||
|
items: Sequence[ItemType],
|
||||||
|
selected_item: Optional[str] = None,
|
||||||
|
menu_args: Optional[Sequence[str]] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
assert items, "No items"
|
||||||
|
|
||||||
|
item_map: Dict[Optional[str], str] = {}
|
||||||
|
item_args: List[str] = []
|
||||||
|
selected_tag: Optional[str] = None
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
item_id = str(i)
|
||||||
|
item_args.append(item_id)
|
||||||
|
|
||||||
|
if isinstance(item, str):
|
||||||
|
item_map[item_id] = item
|
||||||
|
item_args.append(item)
|
||||||
|
|
||||||
|
if selected_item == item:
|
||||||
|
selected_tag = item_id
|
||||||
|
else:
|
||||||
|
item_map[item_id] = item[0]
|
||||||
|
item_args.append(item[1])
|
||||||
|
|
||||||
|
if selected_item == item[0]:
|
||||||
|
selected_tag = item_id
|
||||||
|
|
||||||
|
menu_args = list(menu_args) if menu_args is not None else []
|
||||||
|
if selected_tag is not None:
|
||||||
|
menu_args.extend(["--default-item", selected_tag])
|
||||||
|
|
||||||
|
result = whiptail(
|
||||||
|
"--notags", *menu_args, "--menu", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
||||||
|
)
|
||||||
|
return item_map.get(result)
|
||||||
|
|
||||||
|
|
||||||
|
def inputbox(text: str, init: Optional[Any] = None) -> Optional[str]:
|
||||||
|
return whiptail(
|
||||||
|
"--inputbox", text, HEIGHT, WIDTH, str(init) if init is not None else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def passwordbox(text: str) -> Optional[str]:
|
||||||
|
return whiptail("--passwordbox", text, HEIGHT, WIDTH)
|
||||||
|
|
||||||
|
|
||||||
|
def radiolist(
|
||||||
|
text: str,
|
||||||
|
items: Sequence[ItemType],
|
||||||
|
selected_item: Any,
|
||||||
|
*args,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
assert items, "No items"
|
||||||
|
|
||||||
|
item_map: Dict[str, ItemType] = {}
|
||||||
|
item_args: List[str] = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
item_id = str(i)
|
||||||
|
item_args.append(item_id)
|
||||||
|
|
||||||
|
if isinstance(item, str):
|
||||||
|
item_map[item_id] = item
|
||||||
|
item_args.append(item)
|
||||||
|
|
||||||
|
if item == selected_item:
|
||||||
|
item_args.append("1")
|
||||||
|
else:
|
||||||
|
item_args.append("0")
|
||||||
|
else:
|
||||||
|
item_map[item_id] = item[0]
|
||||||
|
item_args.append(item[1])
|
||||||
|
|
||||||
|
if item[0] == selected_item:
|
||||||
|
item_args.append("1")
|
||||||
|
else:
|
||||||
|
item_args.append("0")
|
||||||
|
|
||||||
|
result = whiptail(
|
||||||
|
"--notags", *args, "--radiolist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return item_map.get(result, result)
|
||||||
|
|
||||||
|
|
||||||
|
def checklist(
|
||||||
|
text: str,
|
||||||
|
items: Sequence[ItemType],
|
||||||
|
selected_items: Sequence[Any],
|
||||||
|
*args,
|
||||||
|
) -> Optional[List[Any]]:
|
||||||
|
assert items, "No items"
|
||||||
|
|
||||||
|
item_map: Dict[str, ItemType] = {}
|
||||||
|
item_args: List[str] = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
item_id = str(i)
|
||||||
|
item_args.append(item_id)
|
||||||
|
|
||||||
|
if isinstance(item, str):
|
||||||
|
item_map[item_id] = item
|
||||||
|
item_args.append(item)
|
||||||
|
|
||||||
|
if item in selected_items:
|
||||||
|
item_args.append("1")
|
||||||
|
else:
|
||||||
|
item_args.append("0")
|
||||||
|
else:
|
||||||
|
item_map[item_id] = item[0]
|
||||||
|
item_args.append(item[1])
|
||||||
|
|
||||||
|
if item[0] in selected_items:
|
||||||
|
item_args.append("1")
|
||||||
|
else:
|
||||||
|
item_args.append("0")
|
||||||
|
|
||||||
|
result = whiptail(
|
||||||
|
"--notags", *args, "--checklist", text, HEIGHT, WIDTH, LIST_HEIGHT, *item_args
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [
|
||||||
|
item_map.get(result_item, result_item) for result_item in shlex.split(result)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def yesno(text: str) -> bool:
|
||||||
|
return whiptail("--yesno", text, HEIGHT, WIDTH) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def msgbox(text: str) -> None:
|
||||||
|
whiptail("--msgbox", text, HEIGHT, WIDTH)
|
||||||
|
|
||||||
|
|
||||||
|
def gauge(text: str, seconds: int, parts: int = 20) -> None:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc.stdin is not None
|
||||||
|
|
||||||
|
percent = 0
|
||||||
|
while percent <= 100:
|
||||||
|
time.sleep(seconds / parts)
|
||||||
|
percent += int(100 / parts)
|
||||||
|
print(percent, file=proc.stdin, flush=True)
|
||||||
|
|
||||||
|
proc.communicate()
|
||||||
|
|
||||||
|
|
||||||
|
def error(reason: str) -> None:
|
||||||
|
msgbox(
|
||||||
|
f"An error occurred while {reason}.\n" "See local/installer.log for details."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_with_gauge(
|
||||||
|
text: str, commands: Sequence[Sequence[str]], sudo_password: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["whiptail", "--title", TITLE, "--gauge", text, HEIGHT, WIDTH, "0"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert proc.stdin is not None
|
||||||
|
percent = 0
|
||||||
|
seconds = 5
|
||||||
|
parts = 20
|
||||||
|
|
||||||
|
with ThreadPoolExecutor() as executor:
|
||||||
|
for command in commands:
|
||||||
|
future = executor.submit(_run_command, command, sudo_password)
|
||||||
|
while not future.done():
|
||||||
|
time.sleep(seconds / parts)
|
||||||
|
percent += int(100 / parts)
|
||||||
|
if percent > 100:
|
||||||
|
percent = 0
|
||||||
|
|
||||||
|
print(percent, file=proc.stdin, flush=True)
|
||||||
|
|
||||||
|
if not future.result():
|
||||||
|
# Error occurred
|
||||||
|
return False
|
||||||
|
|
||||||
|
proc.communicate()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _run_command(command: Sequence[str], sudo_password: Optional[str] = None) -> bool:
|
||||||
|
try:
|
||||||
|
assert command
|
||||||
|
proc_input: Optional[str] = None
|
||||||
|
if (command[0] == "sudo") and (sudo_password is not None):
|
||||||
|
proc_input = sudo_password
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
_stdout, stderr = proc.communicate(proc_input)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
_LOGGER.error("Error running command: %s", command)
|
||||||
|
_LOGGER.error(stderr)
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error running command: %s", command)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
wyoming==1.4.1
|
wyoming==1.5.4
|
||||||
zeroconf==0.88.0
|
zeroconf==0.88.0
|
||||||
pyring-buffer==1.0.0
|
pyring-buffer==1.0.0
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
pysilero-vad==1.0.0
|
|
||||||
webrtc-noise-gain==1.2.3
|
webrtc-noise-gain==1.2.3
|
||||||
@@ -3,3 +3,5 @@ flake8==6.0.0
|
|||||||
isort==5.11.3
|
isort==5.11.3
|
||||||
mypy==0.991
|
mypy==0.991
|
||||||
pylint==2.15.9
|
pylint==2.15.9
|
||||||
|
pytest==7.4.3
|
||||||
|
pytest-asyncio==0.23.3
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
gpiozero
|
||||||
|
spidev
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pysilero-vad==1.0.0
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
docker buildx build . \
|
docker build . \
|
||||||
-t 'rhasspy/wyoming-satellite' "$@"
|
-t 'rhasspy/wyoming-satellite' "$@"
|
||||||
|
|||||||
+7
-2
@@ -7,7 +7,12 @@ _DIR = Path(__file__).parent
|
|||||||
_PROGRAM_DIR = _DIR.parent
|
_PROGRAM_DIR = _DIR.parent
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
||||||
|
_TESTS_DIR = _PROGRAM_DIR / "tests"
|
||||||
|
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
|
||||||
|
_EXAMPLES_DIR = _PROGRAM_DIR / "examples"
|
||||||
|
|
||||||
|
_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR]
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR)])
|
subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS)
|
||||||
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR)])
|
subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS)
|
||||||
|
|||||||
+9
-5
@@ -7,10 +7,14 @@ _DIR = Path(__file__).parent
|
|||||||
_PROGRAM_DIR = _DIR.parent
|
_PROGRAM_DIR = _DIR.parent
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite"
|
||||||
|
_TESTS_DIR = _PROGRAM_DIR / "tests"
|
||||||
|
_INSTALLER_DIR = _PROGRAM_DIR / "installer"
|
||||||
|
|
||||||
|
_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR]
|
||||||
|
|
||||||
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
|
||||||
subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR), "--check"])
|
subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"])
|
||||||
subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR), "--check"])
|
subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"])
|
||||||
subprocess.check_call([context.env_exe, "-m", "flake8", str(_MODULE_DIR)])
|
subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS)
|
||||||
subprocess.check_call([context.env_exe, "-m", "pylint", str(_MODULE_DIR)])
|
subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS)
|
||||||
subprocess.check_call([context.env_exe, "-m", "mypy", str(_MODULE_DIR)])
|
subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS)
|
||||||
|
|||||||
Executable
+15
@@ -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:]
|
||||||
|
)
|
||||||
Executable
+15
@@ -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:]
|
||||||
|
)
|
||||||
Executable
+15
@@ -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:]
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
import venv
|
import venv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,6 +8,9 @@ _DIR = Path(__file__).parent
|
|||||||
_PROGRAM_DIR = _DIR.parent
|
_PROGRAM_DIR = _DIR.parent
|
||||||
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
_VENV_DIR = _PROGRAM_DIR / ".venv"
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--dev", action="store_true", help="Install dev requirements")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
builder = venv.EnvBuilder(with_pip=True)
|
builder = venv.EnvBuilder(with_pip=True)
|
||||||
@@ -23,9 +27,17 @@ subprocess.check_call(
|
|||||||
pip
|
pip
|
||||||
+ [
|
+ [
|
||||||
"install",
|
"install",
|
||||||
|
"--extra-index-url",
|
||||||
|
"https://www.piwheels.org/simple",
|
||||||
"-f",
|
"-f",
|
||||||
"https://synesthesiam.github.io/prebuilt-apps/",
|
"https://synesthesiam.github.io/prebuilt-apps/",
|
||||||
"-r",
|
"-r",
|
||||||
str(_PROGRAM_DIR / "requirements.txt"),
|
str(_PROGRAM_DIR / "requirements.txt"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if args.dev:
|
||||||
|
# Install dev requirements
|
||||||
|
subprocess.check_call(
|
||||||
|
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")]
|
||||||
|
)
|
||||||
|
|||||||
Executable
+13
@@ -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:])
|
||||||
@@ -1,44 +1,63 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import setuptools
|
import setuptools
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
this_dir = Path(__file__).parent
|
this_dir = Path(__file__).parent
|
||||||
|
module_dir = this_dir / "wyoming_satellite"
|
||||||
|
version_path = module_dir / "VERSION"
|
||||||
|
version = version_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
requirements = []
|
|
||||||
requirements_path = this_dir / "requirements.txt"
|
def get_requirements(req_path: Path) -> List[str]:
|
||||||
if requirements_path.is_file():
|
if not req_path.is_file():
|
||||||
with open(requirements_path, "r", encoding="utf-8") as requirements_file:
|
return []
|
||||||
requirements = requirements_file.read().splitlines()
|
|
||||||
|
requirements: List[str] = []
|
||||||
|
with open(req_path, "r", encoding="utf-8") as req_file:
|
||||||
|
for line in req_file:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
requirements.append(line)
|
||||||
|
|
||||||
|
return requirements
|
||||||
|
|
||||||
|
|
||||||
|
install_requires = get_requirements(this_dir / "requirements.txt")
|
||||||
|
extras_require = {
|
||||||
|
"silerovad": get_requirements(this_dir / "requirements_vad.txt"),
|
||||||
|
"webrtc": get_requirements(this_dir / "requirements_audio_enhancement.txt"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="wyoming_satellite",
|
name="wyoming_satellite",
|
||||||
version="1.0.0",
|
version="1.1.0",
|
||||||
description="Wyoming server for remote voice satellite",
|
description="Wyoming server for remote voice satellite",
|
||||||
url="http://github.com/rhasspy/wyoming-satellite",
|
url="http://github.com/rhasspy/wyoming-satellite",
|
||||||
author="Michael Hansen",
|
author="Michael Hansen",
|
||||||
author_email="mike@rhasspy.org",
|
author_email="mike@rhasspy.org",
|
||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
install_requires=requirements,
|
package_data={
|
||||||
|
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
|
||||||
|
},
|
||||||
|
install_requires=install_requires,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Programming Language :: Python :: 3.7",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
],
|
],
|
||||||
keywords="rhasspy wyoming satellite",
|
keywords="rhasspy wyoming satellite",
|
||||||
extras_require={
|
extras_require=extras_require,
|
||||||
"silerovad": ["pysilero-vad==1.0.0"],
|
|
||||||
"webrtc": ["webrtc-noise-gain==1.2.3"],
|
|
||||||
},
|
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
|
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
"""Wyoming satellite tests."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[tox]
|
||||||
|
env_list =
|
||||||
|
py{39,310,311}
|
||||||
|
minversion = 4.12.1
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
description = run the tests with pytest
|
||||||
|
package = wheel
|
||||||
|
wheel_build_env = .pkg
|
||||||
|
deps =
|
||||||
|
pytest>=7,<8
|
||||||
|
pytest-asyncio<1
|
||||||
|
commands =
|
||||||
|
pytest {tty:--color=yes} {posargs}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.3.0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Voice satellite using the Wyoming protocol."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .satellite import (
|
||||||
|
AlwaysStreamingSatellite,
|
||||||
|
SatelliteBase,
|
||||||
|
VadStreamingSatellite,
|
||||||
|
WakeStreamingSatellite,
|
||||||
|
)
|
||||||
|
from .settings import (
|
||||||
|
EventSettings,
|
||||||
|
MicSettings,
|
||||||
|
SatelliteSettings,
|
||||||
|
SndSettings,
|
||||||
|
VadSettings,
|
||||||
|
WakeSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"AlwaysStreamingSatellite",
|
||||||
|
"EventSettings",
|
||||||
|
"MicSettings",
|
||||||
|
"SatelliteBase",
|
||||||
|
"SatelliteSettings",
|
||||||
|
"SndSettings",
|
||||||
|
"VadSettings",
|
||||||
|
"VadStreamingSatellite",
|
||||||
|
"WakeStreamingSatellite",
|
||||||
|
"WakeSettings",
|
||||||
|
]
|
||||||
|
|||||||
+152
-108
@@ -1,19 +1,17 @@
|
|||||||
#!/usr/bin/env python3
|
"""Main entry point for Wyoming satellite."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from wyoming.event import Event
|
from wyoming.info import Attribution, Info, Satellite
|
||||||
from wyoming.info import Attribution, Describe, Info, Satellite
|
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
|
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .event_handler import SatelliteEventHandler
|
||||||
from .satellite import (
|
from .satellite import (
|
||||||
AlwaysStreamingSatellite,
|
AlwaysStreamingSatellite,
|
||||||
SatelliteBase,
|
SatelliteBase,
|
||||||
@@ -25,10 +23,18 @@ from .settings import (
|
|||||||
MicSettings,
|
MicSettings,
|
||||||
SatelliteSettings,
|
SatelliteSettings,
|
||||||
SndSettings,
|
SndSettings,
|
||||||
|
TimerSettings,
|
||||||
VadSettings,
|
VadSettings,
|
||||||
WakeSettings,
|
WakeSettings,
|
||||||
|
WakeWordAndPipeline,
|
||||||
|
)
|
||||||
|
from .utils import (
|
||||||
|
get_mac_address,
|
||||||
|
needs_silero,
|
||||||
|
needs_webrtc,
|
||||||
|
run_event_command,
|
||||||
|
split_command,
|
||||||
)
|
)
|
||||||
from .utils import run_event_command
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger()
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
@@ -70,6 +76,22 @@ async def main() -> None:
|
|||||||
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
"--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-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
|
# Sound output
|
||||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
||||||
@@ -100,7 +122,9 @@ async def main() -> None:
|
|||||||
"--wake-word-name",
|
"--wake-word-name",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
help="Name of wake word to listen for (requires --wake-uri)",
|
nargs="+",
|
||||||
|
metavar=("name", "pipeline"),
|
||||||
|
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -121,6 +145,12 @@ async def main() -> None:
|
|||||||
default=1,
|
default=1,
|
||||||
help="Sample channels of wake-command (default: 1)",
|
help="Sample channels of wake-command (default: 1)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wake-refractory-seconds",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="Seconds after a wake word detection before another detection is handled (default: 5)",
|
||||||
|
)
|
||||||
|
|
||||||
# Voice activity detector
|
# Voice activity detector
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -173,6 +203,10 @@ async def main() -> None:
|
|||||||
"--tts-stop-command",
|
"--tts-stop-command",
|
||||||
help="Command to run when text to speech response stops",
|
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(
|
parser.add_argument(
|
||||||
"--streaming-start-command",
|
"--streaming-start-command",
|
||||||
help="Command to run when audio streaming starts",
|
help="Command to run when audio streaming starts",
|
||||||
@@ -185,6 +219,31 @@ async def main() -> None:
|
|||||||
"--error-command",
|
"--error-command",
|
||||||
help="Command to run when an error occurs",
|
help="Command to run when an error occurs",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--connected-command",
|
||||||
|
help="Command to run when connected to the server",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--disconnected-command",
|
||||||
|
help="Command to run when disconnected from the server",
|
||||||
|
)
|
||||||
|
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
|
# Sounds
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -193,6 +252,17 @@ async def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--done-wav", help="WAV file to play when voice command is done"
|
"--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
|
# Satellite details
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
@@ -214,7 +284,19 @@ async def main() -> None:
|
|||||||
help="Host address for zeroconf discovery (default: detect)",
|
help="Host address for zeroconf discovery (default: detect)",
|
||||||
)
|
)
|
||||||
#
|
#
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-recording-dir", help="Directory to store audio for debugging"
|
||||||
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||||
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Validate args
|
# Validate args
|
||||||
@@ -226,14 +308,14 @@ async def main() -> None:
|
|||||||
try:
|
try:
|
||||||
import webrtc_noise_gain # noqa: F401
|
import webrtc_noise_gain # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_LOGGER.fatal("Install extras for webrtc")
|
_LOGGER.exception("Extras for webrtc are not installed")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if needs_silero(args):
|
if needs_silero(args):
|
||||||
try:
|
try:
|
||||||
import pysilero_vad # noqa: F401
|
import pysilero_vad # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_LOGGER.fatal("Install extras for silerovad")
|
_LOGGER.exception("Extras for silerovad are not installed")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
||||||
@@ -244,12 +326,22 @@ async def main() -> None:
|
|||||||
_LOGGER.fatal("%s does not exist", args.done_wav)
|
_LOGGER.fatal("%s does not exist", args.done_wav)
|
||||||
sys.exit(1)
|
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):
|
if args.vad and (args.wake_uri or args.wake_command):
|
||||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
_LOGGER.warning("VAD is not used with local wake word detection")
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
||||||
|
)
|
||||||
_LOGGER.debug(args)
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
|
if args.debug_recording_dir:
|
||||||
|
args.debug_recording_dir = Path(args.debug_recording_dir)
|
||||||
|
_LOGGER.info("Recording audio to %s", args.debug_recording_dir)
|
||||||
|
|
||||||
wyoming_info = Info(
|
wyoming_info = Info(
|
||||||
satellite=Satellite(
|
satellite=Satellite(
|
||||||
name=args.name,
|
name=args.name,
|
||||||
@@ -257,13 +349,14 @@ async def main() -> None:
|
|||||||
description=args.name,
|
description=args.name,
|
||||||
attribution=Attribution(name="", url=""),
|
attribution=Attribution(name="", url=""),
|
||||||
installed=True,
|
installed=True,
|
||||||
|
version=__version__,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = SatelliteSettings(
|
settings = SatelliteSettings(
|
||||||
mic=MicSettings(
|
mic=MicSettings(
|
||||||
uri=args.mic_uri,
|
uri=args.mic_uri,
|
||||||
command=_split(args.mic_command),
|
command=split_command(args.mic_command),
|
||||||
rate=args.mic_command_rate,
|
rate=args.mic_command_rate,
|
||||||
width=args.mic_command_width,
|
width=args.mic_command_width,
|
||||||
channels=args.mic_command_channels,
|
channels=args.mic_command_channels,
|
||||||
@@ -271,6 +364,9 @@ async def main() -> None:
|
|||||||
volume_multiplier=args.mic_volume_multiplier,
|
volume_multiplier=args.mic_volume_multiplier,
|
||||||
auto_gain=args.mic_auto_gain,
|
auto_gain=args.mic_auto_gain,
|
||||||
noise_suppression=args.mic_noise_suppression,
|
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(
|
vad=VadSettings(
|
||||||
enabled=args.vad,
|
enabled=args.vad,
|
||||||
@@ -281,12 +377,19 @@ async def main() -> None:
|
|||||||
),
|
),
|
||||||
wake=WakeSettings(
|
wake=WakeSettings(
|
||||||
uri=args.wake_uri,
|
uri=args.wake_uri,
|
||||||
command=_split(args.wake_command),
|
command=split_command(args.wake_command),
|
||||||
names=args.wake_word_name,
|
names=[
|
||||||
|
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(
|
snd=SndSettings(
|
||||||
uri=args.snd_uri,
|
uri=args.snd_uri,
|
||||||
command=_split(args.snd_command),
|
command=split_command(args.snd_command),
|
||||||
rate=args.snd_command_rate,
|
rate=args.snd_command_rate,
|
||||||
width=args.snd_command_width,
|
width=args.snd_command_width,
|
||||||
channels=args.snd_command_channels,
|
channels=args.snd_command_channels,
|
||||||
@@ -296,19 +399,32 @@ async def main() -> None:
|
|||||||
),
|
),
|
||||||
event=EventSettings(
|
event=EventSettings(
|
||||||
uri=args.event_uri,
|
uri=args.event_uri,
|
||||||
startup=_split(args.startup_command),
|
startup=split_command(args.startup_command),
|
||||||
streaming_start=_split(args.streaming_start_command),
|
streaming_start=split_command(args.streaming_start_command),
|
||||||
streaming_stop=_split(args.streaming_stop_command),
|
streaming_stop=split_command(args.streaming_stop_command),
|
||||||
detect=_split(args.detect_command),
|
detect=split_command(args.detect_command),
|
||||||
detection=_split(args.detection_command),
|
detection=split_command(args.detection_command),
|
||||||
transcript=_split(args.transcript_command),
|
played=split_command(args.tts_played_command),
|
||||||
stt_start=_split(args.stt_start_command),
|
transcript=split_command(args.transcript_command),
|
||||||
stt_stop=_split(args.stt_stop_command),
|
stt_start=split_command(args.stt_start_command),
|
||||||
synthesize=_split(args.synthesize_command),
|
stt_stop=split_command(args.stt_stop_command),
|
||||||
tts_start=_split(args.tts_start_command),
|
synthesize=split_command(args.synthesize_command),
|
||||||
tts_stop=_split(args.tts_stop_command),
|
tts_start=split_command(args.tts_start_command),
|
||||||
error=_split(args.error_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
|
satellite: SatelliteBase
|
||||||
@@ -324,7 +440,7 @@ async def main() -> None:
|
|||||||
satellite = AlwaysStreamingSatellite(settings)
|
satellite = AlwaysStreamingSatellite(settings)
|
||||||
|
|
||||||
if args.startup_command:
|
if args.startup_command:
|
||||||
await run_event_command(_split(args.startup_command))
|
await run_event_command(split_command(args.startup_command))
|
||||||
|
|
||||||
_LOGGER.info("Ready")
|
_LOGGER.info("Ready")
|
||||||
|
|
||||||
@@ -349,7 +465,7 @@ async def main() -> None:
|
|||||||
args.zeroconf_host,
|
args.zeroconf_host,
|
||||||
)
|
)
|
||||||
|
|
||||||
satellite_task = asyncio.create_task(satellite.run())
|
satellite_task = asyncio.create_task(satellite.run(), name="satellite run")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
|
await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args))
|
||||||
@@ -363,84 +479,12 @@ async def main() -> None:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class SatelliteEventHandler(AsyncEventHandler):
|
def run():
|
||||||
"""Event handler for clients."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
wyoming_info: Info,
|
|
||||||
satellite: SatelliteBase,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.wyoming_info_event = wyoming_info.event()
|
|
||||||
self.client_id = str(time.monotonic_ns())
|
|
||||||
self.satellite = satellite
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
"""Handle events from the server."""
|
|
||||||
if Describe.is_type(event.type):
|
|
||||||
await self.write_event(self.wyoming_info_event)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.satellite.server_id is None:
|
|
||||||
# Take over after a problem occurred
|
|
||||||
self.satellite.set_server(self.client_id, self.writer)
|
|
||||||
elif self.satellite.server_id != self.client_id:
|
|
||||||
# New connection
|
|
||||||
_LOGGER.debug("Connection cancelled: %s", self.client_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
await self.satellite.event_from_server(event)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Server disconnect."""
|
|
||||||
if self.satellite.server_id == self.client_id:
|
|
||||||
self.satellite.clear_server()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_mac_address() -> str:
|
|
||||||
"""Return MAC address formatted as hex with no colons."""
|
|
||||||
return "".join(
|
|
||||||
# pylint: disable=consider-using-f-string
|
|
||||||
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
|
||||||
::-1
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def needs_webrtc(args: argparse.Namespace) -> bool:
|
|
||||||
"""Return True if webrtc must be used."""
|
|
||||||
return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
|
|
||||||
|
|
||||||
|
|
||||||
def needs_silero(args: argparse.Namespace) -> bool:
|
|
||||||
"""Return True if silero-vad must be used."""
|
|
||||||
return args.vad
|
|
||||||
|
|
||||||
|
|
||||||
def _split(command: Optional[str]) -> Optional[List[str]]:
|
|
||||||
if not command:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return shlex.split(command)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
|
|||||||
@@ -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()
|
||||||
+534
-55
@@ -1,33 +1,57 @@
|
|||||||
"""Satellite code."""
|
"""Satellite code."""
|
||||||
|
|
||||||
|
import array
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
import wave
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional, Set, Union
|
from typing import Callable, Dict, Final, List, Optional, Set, Union
|
||||||
|
|
||||||
from pyring_buffer import RingBuffer
|
from pyring_buffer import RingBuffer
|
||||||
from wyoming.asr import Transcript
|
from wyoming.asr import Transcript
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
|
||||||
from wyoming.client import AsyncClient
|
from wyoming.client import AsyncClient
|
||||||
from wyoming.error import Error
|
from wyoming.error import Error
|
||||||
from wyoming.event import Event, async_write_event
|
from wyoming.event import Event, async_write_event
|
||||||
|
from wyoming.info import Describe, Info
|
||||||
from wyoming.mic import MicProcessAsyncClient
|
from wyoming.mic import MicProcessAsyncClient
|
||||||
|
from wyoming.ping import Ping, Pong
|
||||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||||
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
|
from wyoming.satellite import (
|
||||||
from wyoming.snd import SndProcessAsyncClient
|
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.tts import Synthesize
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
||||||
|
|
||||||
from .settings import SatelliteSettings
|
from .settings import SatelliteSettings
|
||||||
from .utils import multiply_volume, run_event_command, wav_to_events
|
from .utils import (
|
||||||
|
DebugAudioWriter,
|
||||||
|
multiply_volume,
|
||||||
|
normalize_wake_word,
|
||||||
|
run_event_command,
|
||||||
|
wav_to_events,
|
||||||
|
)
|
||||||
from .vad import SileroVad
|
from .vad import SileroVad
|
||||||
from .webrtc import WebRtcAudio
|
from .webrtc import WebRtcAudio
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
_PONG_TIMEOUT: Final = 5
|
||||||
|
_PING_SEND_DELAY: Final = 2
|
||||||
|
_WAKE_INFO_TIMEOUT: Final = 2
|
||||||
|
|
||||||
|
|
||||||
class State(Enum):
|
class State(Enum):
|
||||||
NOT_STARTED = auto()
|
NOT_STARTED = auto()
|
||||||
@@ -38,6 +62,12 @@ class State(Enum):
|
|||||||
STOPPED = auto()
|
STOPPED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SoundEvent:
|
||||||
|
event: Event
|
||||||
|
is_tts: bool
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -54,16 +84,36 @@ class SatelliteBase:
|
|||||||
self._mic_task: Optional[asyncio.Task] = None
|
self._mic_task: Optional[asyncio.Task] = None
|
||||||
self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None
|
self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None
|
||||||
self._snd_task: Optional[asyncio.Task] = None
|
self._snd_task: Optional[asyncio.Task] = None
|
||||||
self._snd_queue: "Optional[asyncio.Queue[Event]]" = None
|
self._snd_queue: "Optional[asyncio.Queue[SoundEvent]]" = None
|
||||||
self._wake_task: Optional[asyncio.Task] = None
|
self._wake_task: Optional[asyncio.Task] = None
|
||||||
self._wake_queue: "Optional[asyncio.Queue[Event]]" = None
|
self._wake_queue: "Optional[asyncio.Queue[Event]]" = None
|
||||||
self._event_task: Optional[asyncio.Task] = None
|
self._event_task: Optional[asyncio.Task] = None
|
||||||
self._event_queue: "Optional[asyncio.Queue[Event]]" = None
|
self._event_queue: "Optional[asyncio.Queue[Event]]" = None
|
||||||
|
|
||||||
|
self._ping_server_enabled: bool = False
|
||||||
|
self._pong_received_event = asyncio.Event()
|
||||||
|
self._ping_server_task: 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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""True if not stopping/stopped."""
|
"""True if not stopping/stopped."""
|
||||||
return self._state not in (State.STOPPING, State.STOPPED)
|
return self._state not in (State.STOPPED,)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> State:
|
def state(self) -> State:
|
||||||
@@ -107,17 +157,21 @@ class SatelliteBase:
|
|||||||
while self.state != State.STOPPED:
|
while self.state != State.STOPPED:
|
||||||
await self._state_changed.wait()
|
await self._state_changed.wait()
|
||||||
|
|
||||||
def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None:
|
async def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None:
|
||||||
"""Set event writer."""
|
"""Set event writer."""
|
||||||
self.server_id = server_id
|
self.server_id = server_id
|
||||||
self._writer = writer
|
self._writer = writer
|
||||||
_LOGGER.debug("Server set: %s", server_id)
|
_LOGGER.debug("Server set: %s", server_id)
|
||||||
|
await self.trigger_server_connected()
|
||||||
|
|
||||||
def clear_server(self) -> None:
|
async def clear_server(self) -> None:
|
||||||
"""Remove writer."""
|
"""Remove writer."""
|
||||||
self.server_id = None
|
self.server_id = None
|
||||||
self._writer = None
|
self._writer = None
|
||||||
|
self._disable_ping()
|
||||||
|
|
||||||
_LOGGER.debug("Server disconnected")
|
_LOGGER.debug("Server disconnected")
|
||||||
|
await self.trigger_server_disonnected()
|
||||||
|
|
||||||
async def event_to_server(self, event: Event) -> None:
|
async def event_to_server(self, event: Event) -> None:
|
||||||
"""Send an event to the server."""
|
"""Send an event to the server."""
|
||||||
@@ -127,13 +181,50 @@ class SatelliteBase:
|
|||||||
try:
|
try:
|
||||||
await async_write_event(event, self._writer)
|
await async_write_event(event, self._writer)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self.clear_server()
|
await self.clear_server()
|
||||||
|
|
||||||
if isinstance(err, ConnectionResetError):
|
if isinstance(err, ConnectionResetError):
|
||||||
_LOGGER.warning("Server disconnected unexpectedly")
|
_LOGGER.warning("Server disconnected unexpectedly")
|
||||||
else:
|
else:
|
||||||
_LOGGER.exception("Unexpected error sending event to server")
|
_LOGGER.exception("Unexpected error sending event to server")
|
||||||
|
|
||||||
|
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:
|
async def _start(self) -> None:
|
||||||
@@ -152,6 +243,7 @@ class SatelliteBase:
|
|||||||
self._writer = None
|
self._writer = None
|
||||||
|
|
||||||
await self._disconnect_from_services()
|
await self._disconnect_from_services()
|
||||||
|
self._disable_ping()
|
||||||
self.state = State.STOPPED
|
self.state = State.STOPPED
|
||||||
|
|
||||||
async def stopped(self) -> None:
|
async def stopped(self) -> None:
|
||||||
@@ -159,9 +251,27 @@ class SatelliteBase:
|
|||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
"""Called when an event is received from the server."""
|
"""Called when an event is received from the server."""
|
||||||
if AudioChunk.is_type(event.type):
|
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
|
# TTS audio
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event)
|
||||||
|
forward_event = False
|
||||||
elif AudioStart.is_type(event.type):
|
elif AudioStart.is_type(event.type):
|
||||||
# TTS started
|
# TTS started
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event)
|
||||||
@@ -194,19 +304,33 @@ class SatelliteBase:
|
|||||||
elif Error.is_type(event.type):
|
elif Error.is_type(event.type):
|
||||||
_LOGGER.warning(event)
|
_LOGGER.warning(event)
|
||||||
await self.trigger_error(Error.from_event(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 to event service
|
# Forward everything except audio/ping/pong to event service
|
||||||
if not AudioChunk.is_type(event.type):
|
if forward_event:
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
|
||||||
async def _send_run_pipeline(self) -> None:
|
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
|
||||||
"""Sends a RunPipeline event with the correct stages."""
|
"""Sends a RunPipeline event with the correct stages."""
|
||||||
if self.settings.wake.enabled:
|
if self.settings.wake.enabled:
|
||||||
# Local wake word detection
|
# Local wake word detection
|
||||||
start_stage = PipelineStage.ASR
|
start_stage = PipelineStage.ASR
|
||||||
|
restart_on_end = False
|
||||||
else:
|
else:
|
||||||
# Remote wake word detection
|
# Remote wake word detection
|
||||||
start_stage = PipelineStage.WAKE
|
start_stage = PipelineStage.WAKE
|
||||||
|
restart_on_end = not self.settings.vad.enabled
|
||||||
|
|
||||||
if self.settings.snd.enabled:
|
if self.settings.snd.enabled:
|
||||||
# Play TTS response
|
# Play TTS response
|
||||||
@@ -215,7 +339,18 @@ class SatelliteBase:
|
|||||||
# No audio output
|
# No audio output
|
||||||
end_stage = PipelineStage.HANDLE
|
end_stage = PipelineStage.HANDLE
|
||||||
|
|
||||||
run_pipeline = RunPipeline(start_stage=start_stage, end_stage=end_stage).event()
|
run_pipeline = RunPipeline(
|
||||||
|
start_stage=start_stage,
|
||||||
|
end_stage=end_stage,
|
||||||
|
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.event_to_server(run_pipeline)
|
||||||
await self.forward_event(run_pipeline)
|
await self.forward_event(run_pipeline)
|
||||||
|
|
||||||
@@ -235,28 +370,30 @@ class SatelliteBase:
|
|||||||
"Connecting to mic service: %s",
|
"Connecting to mic service: %s",
|
||||||
self.settings.mic.uri or self.settings.mic.command,
|
self.settings.mic.uri or self.settings.mic.command,
|
||||||
)
|
)
|
||||||
self._mic_task = asyncio.create_task(self._mic_task_proc())
|
self._mic_task = asyncio.create_task(self._mic_task_proc(), name="mic")
|
||||||
|
|
||||||
if self.settings.snd.enabled:
|
if self.settings.snd.enabled:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Connecting to snd service: %s",
|
"Connecting to snd service: %s",
|
||||||
self.settings.snd.uri or self.settings.snd.command,
|
self.settings.snd.uri or self.settings.snd.command,
|
||||||
)
|
)
|
||||||
self._snd_task = asyncio.create_task(self._snd_task_proc())
|
self._snd_task = asyncio.create_task(self._snd_task_proc(), name="snd")
|
||||||
|
|
||||||
if self.settings.wake.enabled:
|
if self.settings.wake.enabled:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Connecting to wake service: %s",
|
"Connecting to wake service: %s",
|
||||||
self.settings.wake.uri or self.settings.wake.command,
|
self.settings.wake.uri or self.settings.wake.command,
|
||||||
)
|
)
|
||||||
self._wake_task = asyncio.create_task(self._wake_task_proc())
|
self._wake_task = asyncio.create_task(self._wake_task_proc(), name="wake")
|
||||||
|
|
||||||
if self.settings.event.enabled:
|
if self.settings.event.enabled:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Connecting to event service: %s",
|
"Connecting to event service: %s",
|
||||||
self.settings.event.uri or self.settings.event.command,
|
self.settings.event.uri or self.settings.event.command,
|
||||||
)
|
)
|
||||||
self._event_task = asyncio.create_task(self._event_task_proc())
|
self._event_task = asyncio.create_task(
|
||||||
|
self._event_task_proc(), name="event"
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.info("Connected to services")
|
_LOGGER.info("Connected to services")
|
||||||
|
|
||||||
@@ -351,15 +488,38 @@ class SatelliteBase:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Audio processing
|
# Audio processing
|
||||||
if self.settings.mic.needs_processing and AudioChunk.is_type(
|
if (
|
||||||
event.type
|
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)
|
chunk = AudioChunk.from_event(event)
|
||||||
audio_bytes = self._process_mic_audio(chunk.audio)
|
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(
|
event = AudioChunk(
|
||||||
rate=chunk.rate,
|
rate=chunk.rate,
|
||||||
width=chunk.width,
|
width=chunk.width,
|
||||||
channels=chunk.channels,
|
channels=(
|
||||||
|
chunk.channels
|
||||||
|
if (self.settings.mic.channel_index is None)
|
||||||
|
else 1
|
||||||
|
),
|
||||||
audio=audio_bytes,
|
audio=audio_bytes,
|
||||||
).event()
|
).event()
|
||||||
else:
|
else:
|
||||||
@@ -393,10 +553,10 @@ class SatelliteBase:
|
|||||||
# Sound
|
# Sound
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def event_to_snd(self, event: Event) -> None:
|
async def event_to_snd(self, event: Event, is_tts: bool = True) -> None:
|
||||||
"""Send an event to the sound service."""
|
"""Send an event to the sound service."""
|
||||||
if self._snd_queue is not None:
|
if self._snd_queue is not None:
|
||||||
self._snd_queue.put_nowait(event)
|
self._snd_queue.put_nowait(SoundEvent(event, is_tts))
|
||||||
|
|
||||||
def _make_snd_client(self) -> Optional[AsyncClient]:
|
def _make_snd_client(self) -> Optional[AsyncClient]:
|
||||||
"""Create client for snd service."""
|
"""Create client for snd service."""
|
||||||
@@ -431,7 +591,8 @@ class SatelliteBase:
|
|||||||
if self._snd_queue is None:
|
if self._snd_queue is None:
|
||||||
self._snd_queue = asyncio.Queue()
|
self._snd_queue = asyncio.Queue()
|
||||||
|
|
||||||
event = await self._snd_queue.get()
|
snd_event = await self._snd_queue.get()
|
||||||
|
event = snd_event.event
|
||||||
|
|
||||||
if snd_client is None:
|
if snd_client is None:
|
||||||
snd_client = self._make_snd_client()
|
snd_client = self._make_snd_client()
|
||||||
@@ -458,6 +619,8 @@ class SatelliteBase:
|
|||||||
event.type
|
event.type
|
||||||
):
|
):
|
||||||
await _disconnect()
|
await _disconnect()
|
||||||
|
if snd_event.is_tts:
|
||||||
|
await self.trigger_played()
|
||||||
snd_client = None # reconnect on next event
|
snd_client = None # reconnect on next event
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@@ -479,17 +642,40 @@ class SatelliteBase:
|
|||||||
|
|
||||||
return audio_bytes
|
return audio_bytes
|
||||||
|
|
||||||
async def _play_wav(self, wav_path: Optional[Union[str, Path]]) -> None:
|
async def _play_wav(
|
||||||
|
self, wav_path: Optional[Union[str, Path]], mute_microphone: bool = False
|
||||||
|
) -> None:
|
||||||
"""Send WAV as events to sound service."""
|
"""Send WAV as events to sound service."""
|
||||||
if (not wav_path) or (not self.settings.snd.enabled):
|
if (not wav_path) or (not self.settings.snd.enabled):
|
||||||
return
|
return
|
||||||
|
|
||||||
for event in wav_to_events(
|
try:
|
||||||
wav_path,
|
if mute_microphone:
|
||||||
samples_per_chunk=self.settings.snd.samples_per_chunk,
|
with wave.open(str(wav_path), "rb") as wav_file:
|
||||||
volume_multiplier=self.settings.snd.volume_multiplier,
|
seconds_to_mute = wav_file.getnframes() / wav_file.getframerate()
|
||||||
):
|
|
||||||
await self.event_to_snd(event)
|
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
|
# Wake
|
||||||
@@ -528,9 +714,19 @@ class SatelliteBase:
|
|||||||
pending: Set[asyncio.Task] = set()
|
pending: Set[asyncio.Task] = set()
|
||||||
|
|
||||||
async def _disconnect() -> None:
|
async def _disconnect() -> None:
|
||||||
|
nonlocal to_client_task, from_client_task
|
||||||
try:
|
try:
|
||||||
if wake_client is not None:
|
if wake_client is not None:
|
||||||
await wake_client.disconnect()
|
await wake_client.disconnect()
|
||||||
|
|
||||||
|
# Clean up tasks
|
||||||
|
if to_client_task is not None:
|
||||||
|
to_client_task.cancel()
|
||||||
|
to_client_task = None
|
||||||
|
|
||||||
|
if from_client_task is not None:
|
||||||
|
from_client_task.cancel()
|
||||||
|
from_client_task = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # ignore disconnect errors
|
pass # ignore disconnect errors
|
||||||
|
|
||||||
@@ -557,12 +753,16 @@ class SatelliteBase:
|
|||||||
# Read/write in "parallel"
|
# Read/write in "parallel"
|
||||||
if to_client_task is None:
|
if to_client_task is None:
|
||||||
# From satellite to wake service
|
# From satellite to wake service
|
||||||
to_client_task = asyncio.create_task(self._wake_queue.get())
|
to_client_task = asyncio.create_task(
|
||||||
|
self._wake_queue.get(), name="wake_to_client"
|
||||||
|
)
|
||||||
pending.add(to_client_task)
|
pending.add(to_client_task)
|
||||||
|
|
||||||
if from_client_task is None:
|
if from_client_task is None:
|
||||||
# From wake service to satellite
|
# From wake service to satellite
|
||||||
from_client_task = asyncio.create_task(wake_client.read_event())
|
from_client_task = asyncio.create_task(
|
||||||
|
wake_client.read_event(), name="wake_from_client"
|
||||||
|
)
|
||||||
pending.add(from_client_task)
|
pending.add(from_client_task)
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
@@ -603,13 +803,36 @@ class SatelliteBase:
|
|||||||
|
|
||||||
async def _send_wake_detect(self) -> None:
|
async def _send_wake_detect(self) -> None:
|
||||||
"""Inform wake word service of which wake words to detect."""
|
"""Inform wake word service of which wake words to detect."""
|
||||||
await self.event_to_wake(Detect(names=self.settings.wake.names).event())
|
wake_names: Optional[List[str]] = None
|
||||||
|
if self.settings.wake.names:
|
||||||
|
wake_names = [w.name for w in self.settings.wake.names]
|
||||||
|
|
||||||
|
await self.event_to_wake(Detect(names=wake_names).event())
|
||||||
|
await self.event_to_wake(
|
||||||
|
AudioStart(
|
||||||
|
rate=self.settings.mic.rate,
|
||||||
|
width=self.settings.mic.width,
|
||||||
|
channels=self.settings.mic.channels,
|
||||||
|
).event()
|
||||||
|
)
|
||||||
await self.trigger_detect()
|
await self.trigger_detect()
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Events
|
# Events
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def trigger_server_connected(self) -> None:
|
||||||
|
"""Called when connected to server."""
|
||||||
|
_LOGGER.info("Connected to server")
|
||||||
|
await run_event_command(self.settings.event.connected)
|
||||||
|
await self.forward_event(SatelliteConnected().event())
|
||||||
|
|
||||||
|
async def trigger_server_disonnected(self) -> None:
|
||||||
|
"""Called when disconnected from server."""
|
||||||
|
_LOGGER.info("Disconnected from server")
|
||||||
|
await run_event_command(self.settings.event.disconnected)
|
||||||
|
await self.forward_event(SatelliteDisconnected().event())
|
||||||
|
|
||||||
async def trigger_streaming_start(self) -> None:
|
async def trigger_streaming_start(self) -> None:
|
||||||
"""Called when audio streaming starts."""
|
"""Called when audio streaming starts."""
|
||||||
await run_event_command(self.settings.event.streaming_start)
|
await run_event_command(self.settings.event.streaming_start)
|
||||||
@@ -627,7 +850,15 @@ class SatelliteBase:
|
|||||||
async def trigger_detection(self, detection: Detection) -> None:
|
async def trigger_detection(self, detection: Detection) -> None:
|
||||||
"""Called when wake word is detected."""
|
"""Called when wake word is detected."""
|
||||||
await run_event_command(self.settings.event.detection, detection.name)
|
await run_event_command(self.settings.event.detection, detection.name)
|
||||||
await self._play_wav(self.settings.snd.awake_wav)
|
await self._play_wav(
|
||||||
|
self.settings.snd.awake_wav,
|
||||||
|
mute_microphone=self.settings.mic.mute_during_awake_wav,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def trigger_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:
|
async def trigger_transcript(self, transcript: Transcript) -> None:
|
||||||
"""Called when speech-to-text text is received."""
|
"""Called when speech-to-text text is received."""
|
||||||
@@ -658,6 +889,28 @@ class SatelliteBase:
|
|||||||
"""Called when an error occurs on the server."""
|
"""Called when an error occurs on the server."""
|
||||||
await run_event_command(self.settings.event.error, error.text)
|
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:
|
async def forward_event(self, event: Event) -> None:
|
||||||
"""Forward an event to the event service."""
|
"""Forward an event to the event service."""
|
||||||
if self._event_queue is not None:
|
if self._event_queue is not None:
|
||||||
@@ -706,6 +959,13 @@ class SatelliteBase:
|
|||||||
|
|
||||||
await _disconnect()
|
await _disconnect()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Info
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def update_info(self, info: Info) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -717,6 +977,12 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
|||||||
super().__init__(settings)
|
super().__init__(settings)
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
|
|
||||||
|
if settings.vad.enabled:
|
||||||
|
_LOGGER.warning("VAD is enabled but will not be used")
|
||||||
|
|
||||||
|
if settings.wake.enabled:
|
||||||
|
_LOGGER.warning("Local wake word detection is enabled but will not be used")
|
||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
await super().event_from_server(event)
|
await super().event_from_server(event)
|
||||||
|
|
||||||
@@ -725,23 +991,44 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
|||||||
_LOGGER.info("Streaming audio")
|
_LOGGER.info("Streaming audio")
|
||||||
await self._send_run_pipeline()
|
await self._send_run_pipeline()
|
||||||
await self.trigger_streaming_start()
|
await self.trigger_streaming_start()
|
||||||
elif Transcript.is_type(event.type):
|
elif PauseSatellite.is_type(event.type):
|
||||||
_LOGGER.info("Streaming audio")
|
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()
|
||||||
|
|
||||||
# Re-trigger streaming start even though we technically don't stop
|
if Transcript.is_type(event.type):
|
||||||
# so the event service can reset LEDs, etc.
|
# We're always streaming
|
||||||
await self.trigger_streaming_start()
|
_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(
|
async def event_from_mic(
|
||||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self.is_streaming:
|
if (not self.is_streaming) or self.microphone_muted:
|
||||||
return
|
return
|
||||||
|
|
||||||
if AudioChunk.is_type(event.type):
|
if AudioChunk.is_type(event.type):
|
||||||
# Forward to server
|
# Forward to server
|
||||||
await self.event_to_server(event)
|
await self.event_to_server(event)
|
||||||
|
|
||||||
|
# Debug audio recording
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
if audio_bytes is None:
|
||||||
|
chunk = AudioChunk.from_event(event)
|
||||||
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -771,18 +1058,58 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2))
|
vad_buffer_bytes = int(math.ceil(settings.vad.buffer_seconds * 16000 * 2))
|
||||||
self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes)
|
self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes)
|
||||||
|
|
||||||
|
if settings.wake.enabled:
|
||||||
|
_LOGGER.warning("Local wake word detection is enabled but will not be used")
|
||||||
|
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
await super().event_from_server(event)
|
await super().event_from_server(event)
|
||||||
|
|
||||||
if RunSatellite.is_type(event.type):
|
if RunSatellite.is_type(event.type):
|
||||||
|
self._is_paused = False
|
||||||
_LOGGER.info("Waiting for speech")
|
_LOGGER.info("Waiting for speech")
|
||||||
|
elif Detection.is_type(event.type):
|
||||||
|
# Start debug recording
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
self.stt_audio_writer.start()
|
||||||
|
elif (
|
||||||
|
Transcript.is_type(event.type)
|
||||||
|
or Error.is_type(event.type)
|
||||||
|
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(
|
async def event_from_mic(
|
||||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if not AudioChunk.is_type(event.type):
|
if (
|
||||||
|
(not AudioChunk.is_type(event.type))
|
||||||
|
or self.microphone_muted
|
||||||
|
or self._is_paused
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only unpack chunk once
|
||||||
|
chunk: Optional[AudioChunk] = None
|
||||||
|
|
||||||
|
# Debug audio recording
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
if audio_bytes is None:
|
||||||
|
# Need to unpack
|
||||||
|
chunk = AudioChunk.from_event(event)
|
||||||
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.is_streaming
|
self.is_streaming
|
||||||
and (self.timeout_seconds is not None)
|
and (self.timeout_seconds is not None)
|
||||||
@@ -792,6 +1119,10 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
self.timeout_seconds = None
|
self.timeout_seconds = None
|
||||||
|
|
||||||
|
# Stop debug recording
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
self.stt_audio_writer.stop()
|
||||||
|
|
||||||
# Stop pipeline
|
# Stop pipeline
|
||||||
await self.event_to_server(AudioStop().event())
|
await self.event_to_server(AudioStop().event())
|
||||||
|
|
||||||
@@ -800,10 +1131,11 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
|
|
||||||
if not self.is_streaming:
|
if not self.is_streaming:
|
||||||
# Check VAD
|
# Check VAD
|
||||||
chunk: Optional[AudioChunk] = None
|
|
||||||
if audio_bytes is None:
|
if audio_bytes is None:
|
||||||
# Need to unpack
|
if chunk is None:
|
||||||
chunk = AudioChunk.from_event(event)
|
# Need to unpack
|
||||||
|
chunk = AudioChunk.from_event(event)
|
||||||
|
|
||||||
audio_bytes = chunk.audio
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
if not self.vad(audio_bytes):
|
if not self.vad(audio_bytes):
|
||||||
@@ -872,26 +1204,108 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
super().__init__(settings)
|
super().__init__(settings)
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
|
|
||||||
|
# Timestamp in the future when the refractory period is over (set with
|
||||||
|
# time.monotonic()).
|
||||||
|
# wake word id -> seconds
|
||||||
|
self.refractory_timestamp: Dict[Optional[str], float] = {}
|
||||||
|
|
||||||
|
if settings.vad.enabled:
|
||||||
|
_LOGGER.warning("VAD is enabled but will not be used")
|
||||||
|
|
||||||
|
# Used for debug audio recording so both wake and stt WAV files have the
|
||||||
|
# same timestamp.
|
||||||
|
self._debug_recording_timestamp: Optional[int] = None
|
||||||
|
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
|
self._wake_info: Optional[Info] = None
|
||||||
|
self._wake_info_ready = asyncio.Event()
|
||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
if Transcript.is_type(event.type):
|
# Only check event types once
|
||||||
|
is_run_satellite = False
|
||||||
|
is_pause_satellite = False
|
||||||
|
is_transcript = False
|
||||||
|
is_error = False
|
||||||
|
|
||||||
|
if RunSatellite.is_type(event.type):
|
||||||
|
is_run_satellite = True
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
|
elif PauseSatellite.is_type(event.type):
|
||||||
|
is_pause_satellite = True
|
||||||
|
elif Transcript.is_type(event.type):
|
||||||
|
is_transcript = True
|
||||||
|
elif Error.is_type(event.type):
|
||||||
|
is_error = True
|
||||||
|
|
||||||
|
if is_transcript or is_pause_satellite:
|
||||||
# Stop streaming before event_from_server is called because it will
|
# Stop streaming before event_from_server is called because it will
|
||||||
# play the "done" WAV.
|
# play the "done" WAV.
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
|
|
||||||
|
# Stop debug recording (stt)
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
self.stt_audio_writer.stop()
|
||||||
|
|
||||||
await super().event_from_server(event)
|
await super().event_from_server(event)
|
||||||
|
|
||||||
if RunSatellite.is_type(event.type) or Transcript.is_type(event.type):
|
if is_run_satellite or is_transcript or is_error or is_pause_satellite:
|
||||||
|
# Stop streaming
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
await self.trigger_streaming_stop()
|
|
||||||
await self._send_wake_detect()
|
if is_pause_satellite:
|
||||||
_LOGGER.info("Waiting for wake word")
|
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(
|
async def event_from_mic(
|
||||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if not AudioChunk.is_type(event.type):
|
if (
|
||||||
|
(not AudioChunk.is_type(event.type))
|
||||||
|
or self.microphone_muted
|
||||||
|
or self._is_paused
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Debug audio recording
|
||||||
|
if (self.wake_audio_writer is not None) or (self.stt_audio_writer is not None):
|
||||||
|
if audio_bytes is None:
|
||||||
|
chunk = AudioChunk.from_event(event)
|
||||||
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
if self.wake_audio_writer is not None:
|
||||||
|
self.wake_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
self.stt_audio_writer.write(audio_bytes)
|
||||||
|
|
||||||
if self.is_streaming:
|
if self.is_streaming:
|
||||||
# Forward to server
|
# Forward to server
|
||||||
await self.event_to_server(event)
|
await self.event_to_server(event)
|
||||||
@@ -900,13 +1314,78 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
await self.event_to_wake(event)
|
await self.event_to_wake(event)
|
||||||
|
|
||||||
async def event_from_wake(self, event: Event) -> None:
|
async def event_from_wake(self, event: Event) -> None:
|
||||||
if self.is_streaming:
|
if Info.is_type(event.type):
|
||||||
|
self._wake_info = Info.from_event(event)
|
||||||
|
self._wake_info_ready.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.is_streaming or (self.server_id is None):
|
||||||
|
# Not detecting or no server connected
|
||||||
return
|
return
|
||||||
|
|
||||||
if Detection.is_type(event.type):
|
if Detection.is_type(event.type):
|
||||||
|
detection = Detection.from_event(event)
|
||||||
|
|
||||||
|
# Check refractory period to avoid multiple back-to-back detections
|
||||||
|
refractory_timestamp = self.refractory_timestamp.get(detection.name)
|
||||||
|
if (refractory_timestamp is not None) and (
|
||||||
|
refractory_timestamp > time.monotonic()
|
||||||
|
):
|
||||||
|
_LOGGER.debug("Wake word detection occurred during refractory period")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Stop debug recording (wake)
|
||||||
|
if self.wake_audio_writer is not None:
|
||||||
|
self.wake_audio_writer.stop()
|
||||||
|
|
||||||
|
# Start debug recording (stt)
|
||||||
|
if self.stt_audio_writer is not None:
|
||||||
|
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
||||||
|
|
||||||
|
_LOGGER.debug(detection)
|
||||||
|
|
||||||
self.is_streaming = True
|
self.is_streaming = True
|
||||||
_LOGGER.debug("Streaming audio")
|
_LOGGER.debug("Streaming audio")
|
||||||
await self._send_run_pipeline()
|
|
||||||
|
if self.settings.wake.refractory_seconds is not None:
|
||||||
|
# Another detection may not occur for this wake word until
|
||||||
|
# refractory period is over.
|
||||||
|
self.refractory_timestamp[detection.name] = (
|
||||||
|
time.monotonic() + self.settings.wake.refractory_seconds
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No refractory period
|
||||||
|
self.refractory_timestamp.pop(detection.name, None)
|
||||||
|
|
||||||
|
# Forward to the server
|
||||||
|
await self.event_to_server(event)
|
||||||
|
|
||||||
|
# Match detected wake word name with pipeline name
|
||||||
|
pipeline_name: Optional[str] = None
|
||||||
|
if self.settings.wake.names:
|
||||||
|
detection_name = normalize_wake_word(detection.name)
|
||||||
|
for wake_name in self.settings.wake.names:
|
||||||
|
if normalize_wake_word(wake_name.name) == detection_name:
|
||||||
|
pipeline_name = wake_name.pipeline
|
||||||
|
break
|
||||||
|
|
||||||
|
await self._send_run_pipeline(pipeline_name=pipeline_name)
|
||||||
await self.forward_event(event) # forward to event service
|
await self.forward_event(event) # forward to event service
|
||||||
await self.trigger_detection(Detection.from_event(event))
|
await self.trigger_detection(Detection.from_event(event))
|
||||||
await self.trigger_streaming_start()
|
await self.trigger_streaming_start()
|
||||||
|
|
||||||
|
async def update_info(self, info: Info) -> None:
|
||||||
|
self._wake_info = None
|
||||||
|
self._wake_info_ready.clear()
|
||||||
|
await self.event_to_wake(Describe().event())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._wake_info_ready.wait(), timeout=_WAKE_INFO_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._wake_info is not None:
|
||||||
|
# Update wake info only
|
||||||
|
info.wake = self._wake_info.wake
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.warning("Failed to get info from wake service")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Satellite settings."""
|
"""Satellite settings."""
|
||||||
|
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +50,15 @@ class MicSettings(ServiceSettings):
|
|||||||
samples_per_chunk: int = 1024
|
samples_per_chunk: int = 1024
|
||||||
"""Samples to read at a time from mic command"""
|
"""Samples to read at a time from mic command"""
|
||||||
|
|
||||||
|
mute_during_awake_wav: bool = True
|
||||||
|
"""True if microphone audio should be muted while the awake WAV is playing."""
|
||||||
|
|
||||||
|
seconds_to_mute_after_awake_wav: float = 0.5
|
||||||
|
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
|
||||||
|
|
||||||
|
channel_index: Optional[int] = None
|
||||||
|
"""Index of microphone channel to use. When not set, all input channels are used as-is."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def needs_webrtc(self) -> bool:
|
def needs_webrtc(self) -> bool:
|
||||||
"""True if webrtc audio enhancements are needed."""
|
"""True if webrtc audio enhancements are needed."""
|
||||||
@@ -93,11 +104,19 @@ class SndSettings(ServiceSettings):
|
|||||||
return self.enabled and (self.volume_multiplier != 1.0)
|
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)
|
@dataclass(frozen=True)
|
||||||
class WakeSettings(ServiceSettings):
|
class WakeSettings(ServiceSettings):
|
||||||
"""Wake word service settings."""
|
"""Wake word service settings."""
|
||||||
|
|
||||||
names: Optional[List[str]] = None
|
names: Optional[List[WakeWordAndPipeline]] = None
|
||||||
"""List of wake word names to listen for."""
|
"""List of wake word names to listen for."""
|
||||||
|
|
||||||
rate: int = 16000
|
rate: int = 16000
|
||||||
@@ -109,6 +128,9 @@ class WakeSettings(ServiceSettings):
|
|||||||
channels: int = 1
|
channels: int = 1
|
||||||
"""Sample channels in wake word audio"""
|
"""Sample channels in wake word audio"""
|
||||||
|
|
||||||
|
refractory_seconds: Optional[float] = 5.0
|
||||||
|
"""Seconds after a wake word detection before another detection is handled."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VadSettings:
|
class VadSettings:
|
||||||
@@ -139,6 +161,7 @@ class EventSettings(ServiceSettings):
|
|||||||
streaming_stop: Optional[List[str]] = None
|
streaming_stop: Optional[List[str]] = None
|
||||||
detect: Optional[List[str]] = None
|
detect: Optional[List[str]] = None
|
||||||
detection: Optional[List[str]] = None
|
detection: Optional[List[str]] = None
|
||||||
|
played: Optional[List[str]] = None
|
||||||
transcript: Optional[List[str]] = None
|
transcript: Optional[List[str]] = None
|
||||||
stt_start: Optional[List[str]] = None
|
stt_start: Optional[List[str]] = None
|
||||||
stt_stop: Optional[List[str]] = None
|
stt_stop: Optional[List[str]] = None
|
||||||
@@ -146,6 +169,34 @@ class EventSettings(ServiceSettings):
|
|||||||
tts_start: Optional[List[str]] = None
|
tts_start: Optional[List[str]] = None
|
||||||
tts_stop: Optional[List[str]] = None
|
tts_stop: Optional[List[str]] = None
|
||||||
error: Optional[List[str]] = None
|
error: Optional[List[str]] = None
|
||||||
|
connected: Optional[List[str]] = None
|
||||||
|
disconnected: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -153,8 +204,13 @@ class SatelliteSettings:
|
|||||||
"""Wyoming satellite settings."""
|
"""Wyoming satellite settings."""
|
||||||
|
|
||||||
mic: MicSettings
|
mic: MicSettings
|
||||||
vad: VadSettings
|
vad: VadSettings = field(default_factory=VadSettings)
|
||||||
wake: WakeSettings
|
wake: WakeSettings = field(default_factory=WakeSettings)
|
||||||
snd: SndSettings
|
snd: SndSettings = field(default_factory=SndSettings)
|
||||||
event: EventSettings
|
event: EventSettings = field(default_factory=EventSettings)
|
||||||
|
timer: TimerSettings = field(default_factory=TimerSettings)
|
||||||
|
|
||||||
restart_timeout: float = 5.0
|
restart_timeout: float = 5.0
|
||||||
|
|
||||||
|
debug_recording_dir: Optional[Path] = None
|
||||||
|
"""Path to directory where debug audio is written."""
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Utility methods."""
|
||||||
|
from .audio import (
|
||||||
|
AudioBuffer,
|
||||||
|
DebugAudioWriter,
|
||||||
|
chunk_samples,
|
||||||
|
multiply_volume,
|
||||||
|
wav_to_events,
|
||||||
|
)
|
||||||
|
from .misc import (
|
||||||
|
get_mac_address,
|
||||||
|
needs_silero,
|
||||||
|
needs_webrtc,
|
||||||
|
normalize_wake_word,
|
||||||
|
run_event_command,
|
||||||
|
split_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AudioBuffer",
|
||||||
|
"chunk_samples",
|
||||||
|
"DebugAudioWriter",
|
||||||
|
"get_mac_address",
|
||||||
|
"multiply_volume",
|
||||||
|
"needs_silero",
|
||||||
|
"needs_webrtc",
|
||||||
|
"normalize_wake_word",
|
||||||
|
"run_event_command",
|
||||||
|
"split_command",
|
||||||
|
"wav_to_events",
|
||||||
|
]
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"""Utilities for Wyoming satellite."""
|
"""Audio utilities."""
|
||||||
import array
|
import array
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import wave
|
import wave
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Iterator, List, Optional, Union
|
from typing import Iterable, Iterator, Optional, Union
|
||||||
|
|
||||||
|
from pyring_buffer import RingBuffer
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
|
|
||||||
@@ -98,27 +99,6 @@ def chunk_samples(
|
|||||||
leftover_chunk_buffer.append(rest_samples)
|
leftover_chunk_buffer.append(rest_samples)
|
||||||
|
|
||||||
|
|
||||||
async def run_event_command(
|
|
||||||
command: Optional[List[str]], command_input: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
"""Run a custom event command with optional input."""
|
|
||||||
if not command:
|
|
||||||
return
|
|
||||||
|
|
||||||
_LOGGER.debug("Running %s", command)
|
|
||||||
program, *program_args = command
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
program, *program_args, stdin=asyncio.subprocess.PIPE
|
|
||||||
)
|
|
||||||
assert proc.stdin is not None
|
|
||||||
|
|
||||||
if command_input:
|
|
||||||
await proc.communicate(input=command_input.encode("utf-8"))
|
|
||||||
else:
|
|
||||||
proc.stdin.close()
|
|
||||||
await proc.wait()
|
|
||||||
|
|
||||||
|
|
||||||
def wav_to_events(
|
def wav_to_events(
|
||||||
wav_path: Union[str, Path],
|
wav_path: Union[str, Path],
|
||||||
samples_per_chunk: int = 1024,
|
samples_per_chunk: int = 1024,
|
||||||
@@ -152,3 +132,74 @@ def wav_to_events(
|
|||||||
audio_bytes = wav_file.readframes(samples_per_chunk)
|
audio_bytes = wav_file.readframes(samples_per_chunk)
|
||||||
|
|
||||||
yield AudioStop(timestamp=timestamp).event()
|
yield AudioStop(timestamp=timestamp).event()
|
||||||
|
|
||||||
|
|
||||||
|
class DebugAudioWriter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dir_path: Union[str, Path],
|
||||||
|
suffix: str,
|
||||||
|
rate: int = 16000,
|
||||||
|
width: int = 2,
|
||||||
|
channels: int = 1,
|
||||||
|
ring_buffer_size: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
self.dir_path = Path(dir_path)
|
||||||
|
self.suffix = suffix
|
||||||
|
self.rate = rate
|
||||||
|
self.width = width
|
||||||
|
self.channels = channels
|
||||||
|
|
||||||
|
self._wav_path: Optional[Path] = None
|
||||||
|
self._wav_writer: Optional[wave.Wave_write] = None
|
||||||
|
|
||||||
|
# If ring buffer size is set, we will hold audio in a ring buffer
|
||||||
|
# instead of directly writing it to disk.
|
||||||
|
#
|
||||||
|
# This allows only the last few seconds of wake word audio to be stored.
|
||||||
|
self._ring_buffer: Optional[RingBuffer] = None
|
||||||
|
if ring_buffer_size is not None:
|
||||||
|
# Hold audio in a ring buffer before writing
|
||||||
|
self._ring_buffer = RingBuffer(ring_buffer_size)
|
||||||
|
|
||||||
|
def start(self, timestamp: Optional[int] = None) -> None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = time.monotonic_ns()
|
||||||
|
|
||||||
|
self._wav_path = self.dir_path / f"{timestamp}-{self.suffix}.wav"
|
||||||
|
self._wav_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._wav_writer = wave.open(str(self._wav_path), "wb")
|
||||||
|
self._wav_writer.setframerate(self.rate)
|
||||||
|
self._wav_writer.setsampwidth(self.width)
|
||||||
|
self._wav_writer.setnchannels(self.channels)
|
||||||
|
|
||||||
|
_LOGGER.debug("Started recording to %s", self._wav_path)
|
||||||
|
|
||||||
|
def write(self, audio: bytes) -> None:
|
||||||
|
if self._wav_writer is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._ring_buffer is not None:
|
||||||
|
# Hold audio in a ring buffer before writing
|
||||||
|
self._ring_buffer.put(audio)
|
||||||
|
else:
|
||||||
|
# Write directly to disk
|
||||||
|
self._wav_writer.writeframes(audio)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self._wav_writer is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._ring_buffer is not None:
|
||||||
|
# Write all audio now and reset buffer
|
||||||
|
self._wav_writer.writeframes(self._ring_buffer.getvalue())
|
||||||
|
self._ring_buffer = RingBuffer(self._ring_buffer.maxlen)
|
||||||
|
|
||||||
|
self._wav_writer.close()
|
||||||
|
self._wav_writer = None
|
||||||
|
|
||||||
|
_LOGGER.debug("Stopped recording to %s", self._wav_path)
|
||||||
|
self._wav_path = None
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user