Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c11e58c4 | ||
|
|
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 | ||
|
|
3ca5b5fd7d | ||
|
|
59ca3df424 |
@@ -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
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ Requires:
|
|||||||
|
|
||||||
## 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
|
||||||
```
|
```
|
||||||
@@ -117,6 +125,8 @@ script/run \
|
|||||||
|
|
||||||
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.
|
Note that `--vad` is unnecessary when connecting to a local instance of openwakeword.
|
||||||
|
|
||||||
## Sounds
|
## Sounds
|
||||||
@@ -125,9 +135,12 @@ You can play a WAV file when the wake word is detected (locally or remotely), an
|
|||||||
|
|
||||||
* `--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:
|
||||||
@@ -163,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.
|
||||||
|
|||||||
+15
-4
@@ -6,7 +6,7 @@ This tutorial should work for almost any Raspberry Pi and USB microphone. Audio
|
|||||||
|
|
||||||
## Install OS
|
## Install OS
|
||||||
|
|
||||||
Follow instructions to [install Raspberry Pi OS](https://www.raspberrypi.com/software/). Under "Choose OS", pick "Raspberry Pi OS (other)" and "Raspberry Pi OS (Legacy, **64-bit**) Lite".
|
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:
|
When asking if you'd like to apply customization settings, choose "Edit Settings" and:
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Clone the `wyoming-satellite` repository:
|
|||||||
git clone https://github.com/rhasspy/wyoming-satellite.git
|
git clone https://github.com/rhasspy/wyoming-satellite.git
|
||||||
```
|
```
|
||||||
|
|
||||||
If you have the ReSpeaker 2Mic or 4Mic HAT, install the drivers:
|
If you have the ReSpeaker 2Mic or 4Mic HAT, recompile and install the drivers (this will take really long time):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd wyoming-satellite/
|
cd wyoming-satellite/
|
||||||
@@ -56,7 +56,8 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pip3 install \
|
.venv/bin/pip3 install \
|
||||||
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
|
-f 'https://synesthesiam.github.io/prebuilt-apps/' \
|
||||||
-r requirements.txt \
|
-r requirements.txt \
|
||||||
-r requirements_extra.txt
|
-r requirements_audio_enhancement.txt \
|
||||||
|
-r requirements_vad.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
If the installation was successful, you should be able to run:
|
If the installation was successful, you should be able to run:
|
||||||
@@ -301,9 +302,16 @@ cd wyoming-satellite/examples
|
|||||||
python3 -m venv --system-site-packages .venv
|
python3 -m venv --system-site-packages .venv
|
||||||
.venv/bin/pip3 install --upgrade pip
|
.venv/bin/pip3 install --upgrade pip
|
||||||
.venv/bin/pip3 install --upgrade wheel setuptools
|
.venv/bin/pip3 install --upgrade wheel setuptools
|
||||||
.venv/bin/pip3 install 'wyoming==1.4.1'
|
.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:
|
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
|
```sh
|
||||||
@@ -384,4 +392,7 @@ Try a voice command and see if the LEDs change. Use `journalctl` to check the lo
|
|||||||
journalctl -u 2mic_leds.service -f
|
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.
|
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
|
||||||
|
```
|
||||||
@@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
set -eo pipefail
|
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 update
|
||||||
apt-get install --no-install-recommends --yes \
|
apt-get install --no-install-recommends --yes \
|
||||||
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
curl raspberrypi-kernel-headers dkms i2c-tools libasound2-plugins alsa-utils
|
||||||
@@ -22,12 +30,11 @@ pushd "${temp_dir}"
|
|||||||
|
|
||||||
# Download source code to temporary directory
|
# Download source code to temporary directory
|
||||||
# NOTE: There are different branches in the repo for different kernel versions.
|
# NOTE: There are different branches in the repo for different kernel versions.
|
||||||
# We use v6.1 here.
|
|
||||||
echo 'Downloading source code'
|
echo 'Downloading source code'
|
||||||
curl -L -o - 'https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v6.1.tar.gz' | \
|
curl -L -o - "https://github.com/HinTak/seeed-voicecard/archive/refs/heads/v$kernel_formatted.tar.gz" | \
|
||||||
tar -xzf -
|
tar -xzf -
|
||||||
|
|
||||||
cd seeed-voicecard-6.1/
|
cd seeed-voicecard-"$kernel_formatted"/
|
||||||
|
|
||||||
# 1. Build kernel module
|
# 1. Build kernel module
|
||||||
echo 'Building kernel module'
|
echo 'Building kernel module'
|
||||||
@@ -36,12 +43,18 @@ mod='seeed-voicecard'
|
|||||||
src='./'
|
src='./'
|
||||||
kernel="$(uname -r)"
|
kernel="$(uname -r)"
|
||||||
marker='0.0.0'
|
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}"
|
mkdir -p "/usr/src/${mod}-${ver}"
|
||||||
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
|
cp -a "${src}"/* "/usr/src/${mod}-${ver}/"
|
||||||
|
|
||||||
dkms add -m "${mod}" -v "${ver}"
|
dkms add -m "${mod}" -v "${ver}"
|
||||||
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" && {
|
dkms build -k "${kernel}" -m "${mod}" -v "${ver}" -j "${threads}" && {
|
||||||
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
|
dkms install --force -k "${kernel}" -m "${mod}" -v "${ver}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ 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
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
NUM_LEDS = 3
|
NUM_LEDS = 3
|
||||||
LEDS_GPIO = 12
|
LEDS_GPIO = 12
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -12,12 +12,18 @@ 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
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
NUM_LEDS = 12
|
NUM_LEDS = 12
|
||||||
LEDS_GPIO = 5
|
LEDS_GPIO = 5
|
||||||
@@ -107,6 +113,15 @@ class LEDsEventHandler(AsyncEventHandler):
|
|||||||
self.color(_BLACK)
|
self.color(_BLACK)
|
||||||
elif RunSatellite.is_type(event.type):
|
elif RunSatellite.is_type(event.type):
|
||||||
self.color(_BLACK)
|
self.color(_BLACK)
|
||||||
|
elif SatelliteConnected.is_type(event.type):
|
||||||
|
# Flash
|
||||||
|
for _ in range(3):
|
||||||
|
self.color(_GREEN)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
self.color(_BLACK)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
elif SatelliteDisconnected.is_type(event.type):
|
||||||
|
self.color(_RED)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from pixel_ring import pixel_ring
|
||||||
|
from wyoming.event import Event
|
||||||
|
from wyoming.satellite import (
|
||||||
|
SatelliteConnected,
|
||||||
|
SatelliteDisconnected,
|
||||||
|
StreamingStarted,
|
||||||
|
StreamingStopped,
|
||||||
|
)
|
||||||
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
from wyoming.snd import Played
|
||||||
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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.2
|
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.1",
|
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(__name__)
|
||||||
|
|
||||||
|
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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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.4.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",
|
||||||
|
]
|
||||||
|
|||||||
+187
-109
@@ -1,19 +1,19 @@
|
|||||||
#!/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 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.audio import AudioFormat
|
||||||
from wyoming.info import Attribution, Describe, Info, Satellite
|
from wyoming.info import Attribution, Info, MicProgram, Satellite, SndProgram
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer
|
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .event_handler import SatelliteEventHandler
|
||||||
from .satellite import (
|
from .satellite import (
|
||||||
AlwaysStreamingSatellite,
|
AlwaysStreamingSatellite,
|
||||||
SatelliteBase,
|
SatelliteBase,
|
||||||
@@ -25,12 +25,19 @@ from .settings import (
|
|||||||
MicSettings,
|
MicSettings,
|
||||||
SatelliteSettings,
|
SatelliteSettings,
|
||||||
SndSettings,
|
SndSettings,
|
||||||
|
TimerSettings,
|
||||||
VadSettings,
|
VadSettings,
|
||||||
WakeSettings,
|
WakeSettings,
|
||||||
)
|
)
|
||||||
from .utils import run_event_command
|
from .utils import (
|
||||||
|
get_mac_address,
|
||||||
|
needs_silero,
|
||||||
|
needs_webrtc,
|
||||||
|
run_event_command,
|
||||||
|
split_command,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_DIR = Path(__file__).parent
|
_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +77,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")
|
||||||
@@ -121,12 +144,23 @@ 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(
|
||||||
"--vad", action="store_true", help="Wait for speech before streaming audio"
|
"--vad", action="store_true", help="Enable voice activity detector"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--vad-threshold",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Threshold for speech (0-1, default: 0.5)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--vad-threshold", type=float, default=0.5)
|
|
||||||
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
parser.add_argument("--vad-trigger-level", type=int, default=1)
|
||||||
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
parser.add_argument("--vad-buffer-seconds", type=float, default=2)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -135,6 +169,7 @@ async def main() -> None:
|
|||||||
default=5.0,
|
default=5.0,
|
||||||
help="Seconds before going back to waiting for speech when wake word isn't detected",
|
help="Seconds before going back to waiting for speech when wake word isn't detected",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("--vad-finished-speaking-seconds", type=float, default=0.8)
|
||||||
|
|
||||||
# External event handlers
|
# External event handlers
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -173,6 +208,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 +224,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 +257,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 +289,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 +313,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 +331,19 @@ 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.vad and (args.wake_uri or args.wake_command):
|
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
|
||||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
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 +351,51 @@ async def main() -> None:
|
|||||||
description=args.name,
|
description=args.name,
|
||||||
attribution=Attribution(name="", url=""),
|
attribution=Attribution(name="", url=""),
|
||||||
installed=True,
|
installed=True,
|
||||||
|
version=__version__,
|
||||||
|
has_vad=args.vad,
|
||||||
|
supports_trigger=True,
|
||||||
|
active_wake_words=args.wake_word_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if args.mic_command:
|
||||||
|
mic_program = shlex.split(args.mic_command)[0]
|
||||||
|
wyoming_info.mic = [
|
||||||
|
MicProgram(
|
||||||
|
name=mic_program,
|
||||||
|
description=args.mic_command,
|
||||||
|
attribution=Attribution(name="", url=""),
|
||||||
|
installed=True,
|
||||||
|
version=None,
|
||||||
|
mic_format=AudioFormat(
|
||||||
|
rate=args.mic_command_rate,
|
||||||
|
width=args.mic_command_width,
|
||||||
|
channels=args.mic_command_channels,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.snd_command:
|
||||||
|
snd_program = shlex.split(args.snd_command)[0]
|
||||||
|
wyoming_info.snd = [
|
||||||
|
SndProgram(
|
||||||
|
name=snd_program,
|
||||||
|
description=args.snd_command,
|
||||||
|
attribution=Attribution(name="", url=""),
|
||||||
|
installed=True,
|
||||||
|
version=None,
|
||||||
|
snd_format=AudioFormat(
|
||||||
|
rate=args.snd_command_rate,
|
||||||
|
width=args.snd_command_width,
|
||||||
|
channels=args.snd_command_channels,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
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 +403,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,
|
||||||
@@ -278,15 +413,21 @@ async def main() -> None:
|
|||||||
trigger_level=args.vad_trigger_level,
|
trigger_level=args.vad_trigger_level,
|
||||||
buffer_seconds=args.vad_buffer_seconds,
|
buffer_seconds=args.vad_buffer_seconds,
|
||||||
wake_word_timeout=args.vad_wake_word_timeout,
|
wake_word_timeout=args.vad_wake_word_timeout,
|
||||||
|
finished_speaking_seconds=args.vad_finished_speaking_seconds,
|
||||||
),
|
),
|
||||||
wake=WakeSettings(
|
wake=WakeSettings(
|
||||||
uri=args.wake_uri,
|
uri=args.wake_uri,
|
||||||
command=_split(args.wake_command),
|
command=split_command(args.wake_command),
|
||||||
names=args.wake_word_name,
|
names=args.wake_word_name,
|
||||||
|
refractory_seconds=(
|
||||||
|
args.wake_refractory_seconds
|
||||||
|
if args.wake_refractory_seconds > 0
|
||||||
|
else None
|
||||||
|
),
|
||||||
),
|
),
|
||||||
snd=SndSettings(
|
snd=SndSettings(
|
||||||
uri=args.snd_uri,
|
uri=args.snd_uri,
|
||||||
command=_split(args.snd_command),
|
command=split_command(args.snd_command),
|
||||||
rate=args.snd_command_rate,
|
rate=args.snd_command_rate,
|
||||||
width=args.snd_command_width,
|
width=args.snd_command_width,
|
||||||
channels=args.snd_command_channels,
|
channels=args.snd_command_channels,
|
||||||
@@ -296,19 +437,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 +478,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 +503,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,82 +517,6 @@ async def main() -> None:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class SatelliteEventHandler(AsyncEventHandler):
|
|
||||||
"""Event handler for clients."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
wyoming_info: Info,
|
|
||||||
satellite: SatelliteBase,
|
|
||||||
cli_args: argparse.Namespace,
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
self.cli_args = cli_args
|
|
||||||
self.wyoming_info_event = wyoming_info.event()
|
|
||||||
self.client_id = str(time.monotonic_ns())
|
|
||||||
self.satellite = satellite
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def handle_event(self, event: Event) -> bool:
|
|
||||||
"""Handle events from the server."""
|
|
||||||
if Describe.is_type(event.type):
|
|
||||||
await self.write_event(self.wyoming_info_event)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.satellite.server_id is None:
|
|
||||||
# Take over after a problem occurred
|
|
||||||
self.satellite.set_server(self.client_id, self.writer)
|
|
||||||
elif self.satellite.server_id != self.client_id:
|
|
||||||
# New connection
|
|
||||||
_LOGGER.debug("Connection cancelled: %s", self.client_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
await self.satellite.event_from_server(event)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""Server disconnect."""
|
|
||||||
if self.satellite.server_id == self.client_id:
|
|
||||||
self.satellite.clear_server()
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def get_mac_address() -> str:
|
|
||||||
"""Return MAC address formatted as hex with no colons."""
|
|
||||||
return "".join(
|
|
||||||
# pylint: disable=consider-using-f-string
|
|
||||||
["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][
|
|
||||||
::-1
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def needs_webrtc(args: argparse.Namespace) -> bool:
|
|
||||||
"""Return True if webrtc must be used."""
|
|
||||||
return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0)
|
|
||||||
|
|
||||||
|
|
||||||
def needs_silero(args: argparse.Namespace) -> bool:
|
|
||||||
"""Return True if silero-vad must be used."""
|
|
||||||
return args.vad
|
|
||||||
|
|
||||||
|
|
||||||
def _split(command: Optional[str]) -> Optional[List[str]]:
|
|
||||||
if not command:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return shlex.split(command)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -8,7 +8,7 @@ from functools import partial
|
|||||||
from wyoming.event import Event
|
from wyoming.event import Event
|
||||||
from wyoming.server import AsyncEventHandler, AsyncServer
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
|
|||||||
+672
-57
File diff suppressed because it is too large
Load Diff
@@ -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."""
|
||||||
@@ -109,6 +120,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:
|
||||||
@@ -129,6 +143,9 @@ class VadSettings:
|
|||||||
wake_word_timeout: Optional[float] = 5.0
|
wake_word_timeout: Optional[float] = 5.0
|
||||||
"""Seconds before going back to sleep if wake word is not detected."""
|
"""Seconds before going back to sleep if wake word is not detected."""
|
||||||
|
|
||||||
|
finished_speaking_seconds: float = 0.8
|
||||||
|
"""Seconds to wait after speech before voice command is finished."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EventSettings(ServiceSettings):
|
class EventSettings(ServiceSettings):
|
||||||
@@ -139,6 +156,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 +164,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 +199,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,15 +1,16 @@
|
|||||||
"""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
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AudioBuffer:
|
class AudioBuffer:
|
||||||
@@ -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(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Voice activity detection."""
|
"""Voice activity detection."""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SileroVad:
|
class SileroVad:
|
||||||
"""Voice activity detection with silero VAD."""
|
"""Voice activity detection with silero VAD."""
|
||||||
@@ -31,3 +35,95 @@ class SileroVad:
|
|||||||
self._activation = max(0, self._activation - 1)
|
self._activation = max(0, self._activation - 1)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoiceCommandSegmenter:
|
||||||
|
"""Segments an audio stream into voice commands."""
|
||||||
|
|
||||||
|
speech_seconds: float = 0.3
|
||||||
|
"""Seconds of speech before voice command has started."""
|
||||||
|
|
||||||
|
silence_seconds: float = 1.0
|
||||||
|
"""Seconds of silence after voice command has ended."""
|
||||||
|
|
||||||
|
timeout_seconds: float = 15.0
|
||||||
|
"""Maximum number of seconds before stopping with timeout=True."""
|
||||||
|
|
||||||
|
reset_seconds: float = 1.0
|
||||||
|
"""Seconds before reset start/stop time counters."""
|
||||||
|
|
||||||
|
in_command: bool = False
|
||||||
|
"""True if inside voice command."""
|
||||||
|
|
||||||
|
_speech_seconds_left: float = 0.0
|
||||||
|
"""Seconds left before considering voice command as started."""
|
||||||
|
|
||||||
|
_silence_seconds_left: float = 0.0
|
||||||
|
"""Seconds left before considering voice command as stopped."""
|
||||||
|
|
||||||
|
_timeout_seconds_left: float = 0.0
|
||||||
|
"""Seconds left before considering voice command timed out."""
|
||||||
|
|
||||||
|
_reset_seconds_left: float = 0.0
|
||||||
|
"""Seconds left before resetting start/stop time counters."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Reset after initialization."""
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset all counters and state."""
|
||||||
|
self._speech_seconds_left = self.speech_seconds
|
||||||
|
self._silence_seconds_left = self.silence_seconds
|
||||||
|
self._timeout_seconds_left = self.timeout_seconds
|
||||||
|
self._reset_seconds_left = self.reset_seconds
|
||||||
|
self.in_command = False
|
||||||
|
|
||||||
|
def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
|
||||||
|
"""Process samples using external VAD.
|
||||||
|
|
||||||
|
Returns False when command is done.
|
||||||
|
"""
|
||||||
|
self._timeout_seconds_left -= chunk_seconds
|
||||||
|
if self._timeout_seconds_left <= 0:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"VAD end of speech detection timed out after %s seconds",
|
||||||
|
self.timeout_seconds,
|
||||||
|
)
|
||||||
|
self.reset()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.in_command:
|
||||||
|
if is_speech:
|
||||||
|
self._reset_seconds_left = self.reset_seconds
|
||||||
|
self._speech_seconds_left -= chunk_seconds
|
||||||
|
if self._speech_seconds_left <= 0:
|
||||||
|
# Inside voice command
|
||||||
|
self.in_command = True
|
||||||
|
self._silence_seconds_left = self.silence_seconds
|
||||||
|
_LOGGER.debug("Voice command started")
|
||||||
|
else:
|
||||||
|
# Reset if enough silence
|
||||||
|
self._reset_seconds_left -= chunk_seconds
|
||||||
|
if self._reset_seconds_left <= 0:
|
||||||
|
self._speech_seconds_left = self.speech_seconds
|
||||||
|
self._reset_seconds_left = self.reset_seconds
|
||||||
|
elif not is_speech:
|
||||||
|
# Silence in command
|
||||||
|
self._reset_seconds_left = self.reset_seconds
|
||||||
|
self._silence_seconds_left -= chunk_seconds
|
||||||
|
if self._silence_seconds_left <= 0:
|
||||||
|
# Command finished successfully
|
||||||
|
self.reset()
|
||||||
|
_LOGGER.debug("Voice command finished")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Speech in command.
|
||||||
|
# Reset silence counter if enough speech.
|
||||||
|
self._reset_seconds_left -= chunk_seconds
|
||||||
|
if self._reset_seconds_left <= 0:
|
||||||
|
self._silence_seconds_left = self.silence_seconds
|
||||||
|
self._reset_seconds_left = self.reset_seconds
|
||||||
|
|
||||||
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user