Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
79dee35659 |
@@ -5,5 +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
|
!sounds/*.wav
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
---
|
||||||
name: test
|
name: test
|
||||||
|
|
||||||
|
# yamllint disable-line rule:truthy
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
include requirements.txt
|
include requirements.txt
|
||||||
|
include VERSION
|
||||||
|
|||||||
@@ -135,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:
|
||||||
@@ -173,10 +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
|
* `--connected-command` - satellite connected to server
|
||||||
* `--disconnected-command` - satellite disconnected from 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.
|
||||||
|
|||||||
+12
-2
@@ -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:
|
||||||
|
|
||||||
@@ -302,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
|
||||||
@@ -385,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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Create a voice satellite using a Raspberry Pi 3+ and USB microphone and speakers
|
|||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,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)
|
||||||
@@ -54,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)
|
||||||
@@ -148,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,
|
||||||
@@ -162,6 +168,7 @@ class APA102:
|
|||||||
self.global_brightness = self.MAX_BRIGHTNESS
|
self.global_brightness = self.MAX_BRIGHTNESS
|
||||||
else:
|
else:
|
||||||
self.global_brightness = global_brightness
|
self.global_brightness = global_brightness
|
||||||
|
_LOGGER.debug("LED brightness: %d", self.global_brightness)
|
||||||
|
|
||||||
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
|
self.leds = [self.LED_START, 0, 0, 0] * self.num_led # Pixel buffer
|
||||||
self.spi = spidev.SpiDev() # Init the SPI device
|
self.spi = spidev.SpiDev() # Init the SPI device
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from wyoming.event import Event
|
||||||
|
from wyoming.satellite import (
|
||||||
|
SatelliteConnected,
|
||||||
|
SatelliteDisconnected,
|
||||||
|
StreamingStarted,
|
||||||
|
StreamingStopped,
|
||||||
|
)
|
||||||
|
from wyoming.snd import Played
|
||||||
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
|
from pixel_ring import pixel_ring
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
|
_LOGGER.info("Ready")
|
||||||
|
|
||||||
|
# Turn on power to LEDs
|
||||||
|
pixel_ring.set_color_palette(0x0080ff, 0x007a37)
|
||||||
|
pixel_ring.think()
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
pixel_ring.off()
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
server = AsyncServer.from_uri(args.uri)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await server.run(partial(LEDsEventHandler, args))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
pixel_ring.off()
|
||||||
|
|
||||||
|
|
||||||
|
class LEDsEventHandler(AsyncEventHandler):
|
||||||
|
"""Event handler for clients."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cli_args: argparse.Namespace,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.cli_args = cli_args
|
||||||
|
self.client_id = str(time.monotonic_ns())
|
||||||
|
|
||||||
|
_LOGGER.debug("Client connected: %s", self.client_id)
|
||||||
|
|
||||||
|
async def handle_event(self, event: Event) -> bool:
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
|
||||||
|
if Detection.is_type(event.type):
|
||||||
|
_LOGGER.debug("Detection")
|
||||||
|
pixel_ring.wakeup()
|
||||||
|
elif VoiceStarted.is_type(event.type):
|
||||||
|
_LOGGER.debug("VoiceStarted")
|
||||||
|
pixel_ring.speak()
|
||||||
|
elif VoiceStopped.is_type(event.type):
|
||||||
|
_LOGGER.debug("VoiceStopped")
|
||||||
|
pixel_ring.spin()
|
||||||
|
elif StreamingStopped.is_type(event.type):
|
||||||
|
_LOGGER.debug("StreamingStopped")
|
||||||
|
pixel_ring.off()
|
||||||
|
elif SatelliteConnected.is_type(event.type):
|
||||||
|
_LOGGER.debug("SatelliteConnected")
|
||||||
|
pixel_ring.think()
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
pixel_ring.off()
|
||||||
|
elif Played.is_type(event.type):
|
||||||
|
_LOGGER.debug("Played")
|
||||||
|
pixel_ring.off()
|
||||||
|
elif SatelliteDisconnected.is_type(event.type):
|
||||||
|
_LOGGER.debug("SatelliteDisconnected")
|
||||||
|
pixel_ring.mono(0xff0000)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
websockets==12.0
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from wyoming.event import Event
|
||||||
|
from wyoming.server import AsyncEventHandler, AsyncServer
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
|
parser.add_argument("--websocket-host", default="localhost")
|
||||||
|
parser.add_argument("--websocket-port", type=int, default=8675)
|
||||||
|
#
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||||
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
|
_LOGGER.info("Ready")
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
server = AsyncServer.from_uri(args.uri)
|
||||||
|
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.serve(
|
||||||
|
partial(websocket_connected, queue),
|
||||||
|
args.websocket_host,
|
||||||
|
args.websocket_port,
|
||||||
|
):
|
||||||
|
await server.run(partial(WebsocketEventHandler, args, queue))
|
||||||
|
finally:
|
||||||
|
queue.put_nowait(None)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
event = await queue.get()
|
||||||
|
if event is None:
|
||||||
|
# Stop signal
|
||||||
|
break
|
||||||
|
|
||||||
|
await websocket.send(
|
||||||
|
json.dumps(
|
||||||
|
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Error in websocket handler")
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class WebsocketEventHandler(AsyncEventHandler):
|
||||||
|
"""Event handler for clients."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cli_args: argparse.Namespace,
|
||||||
|
queue: "asyncio.Queue[Optional[Event]]",
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.cli_args = cli_args
|
||||||
|
self.queue = queue
|
||||||
|
|
||||||
|
async def handle_event(self, event: Event) -> bool:
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
self.queue.put_nowait(event)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
Binary file not shown.
@@ -0,0 +1,244 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="Wyoming satellite timer example">
|
||||||
|
<meta name="author" content="Michael Hansen">
|
||||||
|
|
||||||
|
<title>Wyoming Timer Example</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
display: flex;
|
||||||
|
width: 100px;
|
||||||
|
height: auto;
|
||||||
|
flex-wrap:wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #03a9f4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
|
color: white;
|
||||||
|
margin-right: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
text-align: center;
|
||||||
|
width: 90px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paused {
|
||||||
|
background-color: yellow;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink-animation 1s linear infinite;
|
||||||
|
-webkit-animation: blink-animation 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink-animation {
|
||||||
|
0%, 100% {opacity: 1;}
|
||||||
|
50% {opacity: 0;}
|
||||||
|
}
|
||||||
|
@-webkit-keyframes blink-animation {
|
||||||
|
0%, 100% {opacity: 1;}
|
||||||
|
50% {opacity: 0;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="timers" class="timer-container">
|
||||||
|
</div>
|
||||||
|
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let socket = new WebSocket("ws://localhost:8675")
|
||||||
|
var timers = []
|
||||||
|
var finished_timers = 0
|
||||||
|
|
||||||
|
function q(selector) {return document.querySelector(selector)}
|
||||||
|
|
||||||
|
socket.onopen = function(e) {
|
||||||
|
console.log("Connected")
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = function(event) {
|
||||||
|
wyo_event = JSON.parse(event.data)
|
||||||
|
if (wyo_event.type == "timer-started") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
timer_info.is_active = true
|
||||||
|
console.log(timer_info)
|
||||||
|
|
||||||
|
timers.push(timer_info)
|
||||||
|
|
||||||
|
let timers_elem = q("#timers")
|
||||||
|
let timer_div = document.createElement("div")
|
||||||
|
timer_div.id = "timer-" + timer_info.id
|
||||||
|
timer_div.classList.add("timer")
|
||||||
|
|
||||||
|
let message_div = document.createElement("div")
|
||||||
|
message_div.classList.add("message")
|
||||||
|
|
||||||
|
if (timer_info.name) {
|
||||||
|
message_div.innerHTML = timer_info.name
|
||||||
|
} else {
|
||||||
|
if (timer_info.start_hours) {
|
||||||
|
message_div.innerHTML += timer_info.start_hours + " hr "
|
||||||
|
}
|
||||||
|
if (timer_info.start_minutes) {
|
||||||
|
message_div.innerHTML += timer_info.start_minutes + " min "
|
||||||
|
}
|
||||||
|
if (timer_info.start_seconds) {
|
||||||
|
message_div.innerHTML += timer_info.start_seconds + " sec "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer_div.appendChild(message_div)
|
||||||
|
|
||||||
|
let time_div = document.createElement("div")
|
||||||
|
time_div.classList.add("time")
|
||||||
|
|
||||||
|
let hours = timer_info.start_hours ?? 0
|
||||||
|
let minutes = timer_info.start_minutes ?? 0
|
||||||
|
let seconds = timer_info.start_seconds ?? 0
|
||||||
|
|
||||||
|
time_div.innerHTML =
|
||||||
|
hours.toString().padStart(2, 0) + ":" +
|
||||||
|
minutes.toString().padStart(2, 0) + ":" +
|
||||||
|
seconds.toString().padStart(2, 0);
|
||||||
|
|
||||||
|
timer_div.appendChild(time_div)
|
||||||
|
|
||||||
|
timers_elem.appendChild(timer_div)
|
||||||
|
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-cancelled") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
timers = timers.filter(t => t.id != timer_info.id)
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
timer_div.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-updated") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
for (i = 0; i < timers.length; i++) {
|
||||||
|
if (timers[i].id == timer_info.id) {
|
||||||
|
timers[i].is_active = timer_info.is_active
|
||||||
|
timers[i].total_seconds = timer_info.total_seconds
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
if (timer_info.is_active) {
|
||||||
|
timer_div.classList.remove("paused")
|
||||||
|
} else {
|
||||||
|
timer_div.classList.add("paused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (wyo_event.type == "timer-finished") {
|
||||||
|
let timer_info = wyo_event.data
|
||||||
|
for (i = 0; i < timers.length; i++) {
|
||||||
|
if (timers[i].id == timer_info.id) {
|
||||||
|
timers[i].total_seconds = 0
|
||||||
|
finished_timers += 1
|
||||||
|
if (finished_timers == 1) {
|
||||||
|
q("#audio-finished").play()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timers = timers.filter(t => t.id != timer_info.id)
|
||||||
|
let timer_div = q("#timer-" + timer_info.id)
|
||||||
|
if (timer_div) {
|
||||||
|
timer_div.classList.add("finished")
|
||||||
|
timer_div.classList.add("blink")
|
||||||
|
|
||||||
|
timer_div.onclick = function() {
|
||||||
|
timer_div.remove()
|
||||||
|
finished_timers = Math.max(0, finished_timers - 1)
|
||||||
|
if (finished_timers == 0) {
|
||||||
|
let audio = q("#audio-finished")
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_div = q("#timer-" + timer_info.id + " .time")
|
||||||
|
if (time_div) {
|
||||||
|
time_div.style.opacity = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function(event) {
|
||||||
|
console.log("Disconnected")
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function(error) {
|
||||||
|
};
|
||||||
|
|
||||||
|
function update_timer(timer_info) {
|
||||||
|
if (timer_info.total_seconds < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_div = q("#timer-" + timer_info.id + " .time")
|
||||||
|
if (!time_div) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timer_info.is_active) {
|
||||||
|
// Paused
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timer_info.total_seconds > 0) {
|
||||||
|
timer_info.total_seconds -= 1
|
||||||
|
}
|
||||||
|
let total_minutes = Math.floor(timer_info.total_seconds / 60)
|
||||||
|
|
||||||
|
let hours = Math.floor(total_minutes / 60)
|
||||||
|
let minutes = total_minutes % 60
|
||||||
|
let seconds = timer_info.total_seconds % 60
|
||||||
|
|
||||||
|
time_div.innerHTML =
|
||||||
|
hours.toString().padStart(2, 0) + ":" +
|
||||||
|
minutes.toString().padStart(2, 0) + ":" +
|
||||||
|
seconds.toString().padStart(2, 0)
|
||||||
|
|
||||||
|
setTimeout(update_timer, 1000, timer_info)
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
wyoming==1.5.0
|
wyoming==1.5.4
|
||||||
zeroconf==0.88.0
|
zeroconf==0.88.0
|
||||||
pyring-buffer==1.0.0
|
pyring-buffer==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' "$@"
|
||||||
|
|||||||
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:]
|
||||||
|
)
|
||||||
@@ -6,6 +6,9 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
def get_requirements(req_path: Path) -> List[str]:
|
def get_requirements(req_path: Path) -> List[str]:
|
||||||
@@ -41,13 +44,14 @@ setup(
|
|||||||
author="Michael Hansen",
|
author="Michael Hansen",
|
||||||
author_email="mike@rhasspy.org",
|
author_email="mike@rhasspy.org",
|
||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
|
package_data={
|
||||||
|
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
|
||||||
|
},
|
||||||
install_requires=install_requires,
|
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",
|
||||||
|
|||||||
Binary file not shown.
+59
-1
@@ -1,6 +1,64 @@
|
|||||||
"""Shared code for Wyoming satellite tests."""
|
"""Shared code for Wyoming satellite tests."""
|
||||||
from wyoming.audio import AudioChunk
|
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(
|
AUDIO_CHUNK = AudioChunk(
|
||||||
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
|
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStreamReaderWriter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._undrained_data = bytes()
|
||||||
|
self._value = bytes()
|
||||||
|
self._data_ready = asyncio.Event()
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> None:
|
||||||
|
self._undrained_data += data
|
||||||
|
|
||||||
|
def writelines(self, data: Iterable[bytes]) -> None:
|
||||||
|
for line in data:
|
||||||
|
self.write(line)
|
||||||
|
|
||||||
|
async def drain(self) -> None:
|
||||||
|
self._value += self._undrained_data
|
||||||
|
self._undrained_data = bytes()
|
||||||
|
self._data_ready.set()
|
||||||
|
self._data_ready.clear()
|
||||||
|
|
||||||
|
async def readline(self) -> bytes:
|
||||||
|
while b"\n" not in self._value:
|
||||||
|
await self._data_ready.wait()
|
||||||
|
|
||||||
|
with io.BytesIO(self._value) as value_io:
|
||||||
|
data = value_io.readline()
|
||||||
|
self._value = self._value[len(data) :]
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def readexactly(self, n: int) -> bytes:
|
||||||
|
while len(self._value) < n:
|
||||||
|
await self._data_ready.wait()
|
||||||
|
|
||||||
|
data = self._value[:n]
|
||||||
|
self._value = self._value[n:]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class MicClient(AsyncClient):
|
||||||
|
async def read_event(self) -> Optional[Event]:
|
||||||
|
# Send 30ms of audio every 30ms
|
||||||
|
await asyncio.sleep(AUDIO_CHUNK.seconds)
|
||||||
|
return AUDIO_CHUNK.event()
|
||||||
|
|
||||||
|
async def write_event(self, event: Event) -> None:
|
||||||
|
# Output only
|
||||||
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Final, Optional
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from wyoming.asr import Transcript
|
||||||
|
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||||
|
from wyoming.client import AsyncClient
|
||||||
|
from wyoming.event import Event, async_read_event
|
||||||
|
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||||
|
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
|
||||||
|
from wyoming.tts import Synthesize
|
||||||
|
from wyoming.wake import Detection
|
||||||
|
|
||||||
|
from wyoming_satellite import (
|
||||||
|
EventSettings,
|
||||||
|
MicSettings,
|
||||||
|
SatelliteSettings,
|
||||||
|
SndSettings,
|
||||||
|
WakeSettings,
|
||||||
|
WakeStreamingSatellite,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .shared import (
|
||||||
|
AUDIO_CHUNK,
|
||||||
|
AUDIO_START,
|
||||||
|
AUDIO_STOP,
|
||||||
|
FakeStreamReaderWriter,
|
||||||
|
MicClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
TIMEOUT: Final = 1
|
||||||
|
|
||||||
|
|
||||||
|
class WakeClient(AsyncClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._event_ready = asyncio.Event()
|
||||||
|
self._event: Optional[Event] = None
|
||||||
|
self._detected: bool = False
|
||||||
|
|
||||||
|
async def read_event(self) -> Optional[Event]:
|
||||||
|
await self._event_ready.wait()
|
||||||
|
self._event_ready.clear()
|
||||||
|
return self._event
|
||||||
|
|
||||||
|
async def write_event(self, event: Event) -> None:
|
||||||
|
if AudioChunk.is_type(event.type):
|
||||||
|
if not self._detected:
|
||||||
|
self._detected = True
|
||||||
|
self._event = Detection().event()
|
||||||
|
self._event_ready.set()
|
||||||
|
|
||||||
|
|
||||||
|
class SndClient(AsyncClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.synthesize = asyncio.Event()
|
||||||
|
self.audio_start = asyncio.Event()
|
||||||
|
self.audio_chunk = asyncio.Event()
|
||||||
|
self.audio_stop = asyncio.Event()
|
||||||
|
|
||||||
|
async def read_event(self) -> Optional[Event]:
|
||||||
|
# Input only
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def write_event(self, event: Event) -> None:
|
||||||
|
if AudioChunk.is_type(event.type):
|
||||||
|
self.audio_chunk.set()
|
||||||
|
elif Synthesize.is_type(event.type):
|
||||||
|
self.synthesize.set()
|
||||||
|
elif AudioStart.is_type(event.type):
|
||||||
|
self.audio_start.set()
|
||||||
|
elif AudioStop.is_type(event.type):
|
||||||
|
self.audio_stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
class EventClient(AsyncClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.detection = asyncio.Event()
|
||||||
|
self.streaming_started = asyncio.Event()
|
||||||
|
self.streaming_stopped = asyncio.Event()
|
||||||
|
self.transcript = asyncio.Event()
|
||||||
|
self.synthesize = asyncio.Event()
|
||||||
|
self.audio_start = asyncio.Event()
|
||||||
|
self.audio_chunk = asyncio.Event()
|
||||||
|
self.audio_stop = asyncio.Event()
|
||||||
|
|
||||||
|
async def read_event(self) -> Optional[Event]:
|
||||||
|
# Input only
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def write_event(self, event: Event) -> None:
|
||||||
|
if Detection.is_type(event.type):
|
||||||
|
self.detection.set()
|
||||||
|
elif StreamingStarted.is_type(event.type):
|
||||||
|
self.streaming_started.set()
|
||||||
|
elif StreamingStopped.is_type(event.type):
|
||||||
|
self.streaming_stopped.set()
|
||||||
|
elif Transcript.is_type(event.type):
|
||||||
|
self.transcript.set()
|
||||||
|
elif Synthesize.is_type(event.type):
|
||||||
|
self.synthesize.set()
|
||||||
|
elif AudioChunk.is_type(event.type):
|
||||||
|
self.audio_chunk.set()
|
||||||
|
elif AudioStart.is_type(event.type):
|
||||||
|
self.audio_start.set()
|
||||||
|
elif AudioStop.is_type(event.type):
|
||||||
|
self.audio_stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wake_satellite() -> None:
|
||||||
|
mic_client = MicClient()
|
||||||
|
snd_client = SndClient()
|
||||||
|
wake_client = WakeClient()
|
||||||
|
event_client = EventClient()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
|
||||||
|
return_value=mic_client,
|
||||||
|
), patch(
|
||||||
|
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
|
||||||
|
return_value=snd_client,
|
||||||
|
), patch(
|
||||||
|
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
|
||||||
|
return_value=wake_client,
|
||||||
|
), patch(
|
||||||
|
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
|
||||||
|
return_value=event_client,
|
||||||
|
):
|
||||||
|
satellite = WakeStreamingSatellite(
|
||||||
|
SatelliteSettings(
|
||||||
|
mic=MicSettings(uri="test"),
|
||||||
|
snd=SndSettings(uri="test"),
|
||||||
|
wake=WakeSettings(uri="test"),
|
||||||
|
event=EventSettings(uri="test"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def event_from_satellite() -> Optional[Event]:
|
||||||
|
return await async_read_event(server_io)
|
||||||
|
|
||||||
|
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
||||||
|
|
||||||
|
# Fake server connection
|
||||||
|
server_io = FakeStreamReaderWriter()
|
||||||
|
await satellite.set_server("test", server_io) # type: ignore
|
||||||
|
|
||||||
|
# Start satellite
|
||||||
|
await satellite.event_from_server(RunSatellite().event())
|
||||||
|
|
||||||
|
# Trigger detection
|
||||||
|
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||||
|
assert event is not None
|
||||||
|
assert Detection.is_type(event.type), event
|
||||||
|
|
||||||
|
# Pipeline should start
|
||||||
|
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||||
|
assert event is not None
|
||||||
|
assert RunPipeline.is_type(event.type), event
|
||||||
|
run_pipeline = RunPipeline.from_event(event)
|
||||||
|
assert run_pipeline.start_stage == PipelineStage.ASR
|
||||||
|
assert run_pipeline.end_stage == PipelineStage.TTS
|
||||||
|
|
||||||
|
# Event service should have received detection
|
||||||
|
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Server should be receiving audio now
|
||||||
|
assert satellite.is_streaming, "Not streaming"
|
||||||
|
for _ in range(5):
|
||||||
|
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||||
|
assert event is not None
|
||||||
|
assert AudioChunk.is_type(event.type)
|
||||||
|
|
||||||
|
# Event service should have received streaming start
|
||||||
|
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Send transcript
|
||||||
|
await satellite.event_from_server(Transcript(text="test").event())
|
||||||
|
|
||||||
|
# Event service should have received transcript
|
||||||
|
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Wait for streaming to stop
|
||||||
|
while satellite.is_streaming:
|
||||||
|
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||||
|
assert event is not None
|
||||||
|
assert AudioChunk.is_type(event.type)
|
||||||
|
|
||||||
|
# Event service should have received streaming stop
|
||||||
|
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Fake a TTS response
|
||||||
|
await satellite.event_from_server(Synthesize(text="test").event())
|
||||||
|
|
||||||
|
# Event service should have received synthesize
|
||||||
|
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Audio start, chunk, stop
|
||||||
|
await satellite.event_from_server(AUDIO_START.event())
|
||||||
|
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
|
||||||
|
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Event service does not get audio chunks, just start/stop
|
||||||
|
await satellite.event_from_server(AUDIO_CHUNK.event())
|
||||||
|
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
await satellite.event_from_server(AUDIO_STOP.event())
|
||||||
|
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
|
||||||
|
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
|
||||||
|
|
||||||
|
# Stop satellite
|
||||||
|
await satellite.stop()
|
||||||
|
await satellite_task
|
||||||
@@ -20,22 +20,11 @@ from wyoming_satellite import (
|
|||||||
WakeStreamingSatellite,
|
WakeStreamingSatellite,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .shared import AUDIO_CHUNK
|
from .shared import MicClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
class MicClient(AsyncClient):
|
|
||||||
async def read_event(self) -> Optional[Event]:
|
|
||||||
# Send 30ms of audio every 30ms
|
|
||||||
await asyncio.sleep(AUDIO_CHUNK.seconds)
|
|
||||||
return AUDIO_CHUNK.event()
|
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
|
||||||
# Output only
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class WakeClient(AsyncClient):
|
class WakeClient(AsyncClient):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -60,7 +49,6 @@ class EventClient(AsyncClient):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def write_event(self, event: Event) -> None:
|
async def write_event(self, event: Event) -> None:
|
||||||
_LOGGER.error(event.type)
|
|
||||||
if Detection.is_type(event.type):
|
if Detection.is_type(event.type):
|
||||||
self.wake_event.set()
|
self.wake_event.set()
|
||||||
|
|
||||||
@@ -89,6 +77,9 @@ async def test_multiple_wakeups(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fake server connection
|
||||||
|
satellite.server_id = "test"
|
||||||
|
|
||||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
||||||
await satellite.event_from_server(RunSatellite().event())
|
await satellite.event_from_server(RunSatellite().event())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[tox]
|
||||||
|
env_list =
|
||||||
|
py{39,310,311}
|
||||||
|
minversion = 4.12.1
|
||||||
|
|
||||||
|
[testenv]
|
||||||
|
description = run the tests with pytest
|
||||||
|
package = wheel
|
||||||
|
wheel_build_env = .pkg
|
||||||
|
deps =
|
||||||
|
pytest>=7,<8
|
||||||
|
pytest-asyncio<1
|
||||||
|
commands =
|
||||||
|
pytest {tty:--color=yes} {posargs}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.3.0
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Voice satellite using the Wyoming protocol."""
|
"""Voice satellite using the Wyoming protocol."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .satellite import (
|
from .satellite import (
|
||||||
AlwaysStreamingSatellite,
|
AlwaysStreamingSatellite,
|
||||||
SatelliteBase,
|
SatelliteBase,
|
||||||
@@ -14,7 +16,11 @@ from .settings import (
|
|||||||
WakeSettings,
|
WakeSettings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_DIR = Path(__file__).parent
|
||||||
|
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
"AlwaysStreamingSatellite",
|
"AlwaysStreamingSatellite",
|
||||||
"EventSettings",
|
"EventSettings",
|
||||||
"MicSettings",
|
"MicSettings",
|
||||||
@@ -23,6 +29,6 @@ __all__ = [
|
|||||||
"SndSettings",
|
"SndSettings",
|
||||||
"VadSettings",
|
"VadSettings",
|
||||||
"VadStreamingSatellite",
|
"VadStreamingSatellite",
|
||||||
"WakeSettings",
|
|
||||||
"WakeStreamingSatellite",
|
"WakeStreamingSatellite",
|
||||||
|
"WakeSettings",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Main entry point for Wyoming satellite."""
|
"""Main entry point for Wyoming satellite."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
|||||||
from wyoming.info import Attribution, Info, Satellite
|
from wyoming.info import Attribution, Info, Satellite
|
||||||
from wyoming.server import AsyncServer, AsyncTcpServer
|
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
from .event_handler import SatelliteEventHandler
|
from .event_handler import SatelliteEventHandler
|
||||||
from .satellite import (
|
from .satellite import (
|
||||||
AlwaysStreamingSatellite,
|
AlwaysStreamingSatellite,
|
||||||
@@ -21,8 +23,10 @@ from .settings import (
|
|||||||
MicSettings,
|
MicSettings,
|
||||||
SatelliteSettings,
|
SatelliteSettings,
|
||||||
SndSettings,
|
SndSettings,
|
||||||
|
TimerSettings,
|
||||||
VadSettings,
|
VadSettings,
|
||||||
WakeSettings,
|
WakeSettings,
|
||||||
|
WakeWordAndPipeline,
|
||||||
)
|
)
|
||||||
from .utils import (
|
from .utils import (
|
||||||
get_mac_address,
|
get_mac_address,
|
||||||
@@ -72,6 +76,22 @@ async def main() -> None:
|
|||||||
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
||||||
)
|
)
|
||||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||||
|
parser.add_argument(
|
||||||
|
"--mic-seconds-to-mute-after-awake-wav",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mic-no-mute-during-awake-wav",
|
||||||
|
action="store_true",
|
||||||
|
help="Don't mute the microphone while awake wav is being played",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mic-channel-index",
|
||||||
|
type=int,
|
||||||
|
help="Take microphone input from a specific channel (first channel is 0)",
|
||||||
|
)
|
||||||
|
|
||||||
# Sound output
|
# Sound output
|
||||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
||||||
@@ -102,7 +122,9 @@ async def main() -> None:
|
|||||||
"--wake-word-name",
|
"--wake-word-name",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
help="Name of wake word to listen for (requires --wake-uri)",
|
nargs="+",
|
||||||
|
metavar=("name", "pipeline"),
|
||||||
|
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -181,6 +203,10 @@ async def main() -> None:
|
|||||||
"--tts-stop-command",
|
"--tts-stop-command",
|
||||||
help="Command to run when text to speech response stops",
|
help="Command to run when text to speech response stops",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tts-played-command",
|
||||||
|
help="Command to run when text-to-speech audio stopped playing",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--streaming-start-command",
|
"--streaming-start-command",
|
||||||
help="Command to run when audio streaming starts",
|
help="Command to run when audio streaming starts",
|
||||||
@@ -201,6 +227,23 @@ async def main() -> None:
|
|||||||
"--disconnected-command",
|
"--disconnected-command",
|
||||||
help="Command to run when disconnected from the server",
|
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(
|
||||||
@@ -209,6 +252,17 @@ async def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--done-wav", help="WAV file to play when voice command is done"
|
"--done-wav", help="WAV file to play when voice command is done"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timer-finished-wav", help="WAV file to play when a timer finishes"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timer-finished-wav-repeat",
|
||||||
|
nargs=2,
|
||||||
|
metavar=("repeat", "delay"),
|
||||||
|
type=float,
|
||||||
|
default=(1, 0),
|
||||||
|
help="Number of times to play timer finished WAV and delay between repeats in seconds",
|
||||||
|
)
|
||||||
|
|
||||||
# Satellite details
|
# Satellite details
|
||||||
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
|
||||||
@@ -234,6 +288,15 @@ async def main() -> None:
|
|||||||
"--debug-recording-dir", help="Directory to store audio for debugging"
|
"--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
|
||||||
@@ -245,14 +308,14 @@ async def main() -> None:
|
|||||||
try:
|
try:
|
||||||
import webrtc_noise_gain # noqa: F401
|
import webrtc_noise_gain # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_LOGGER.fatal("Install extras for webrtc")
|
_LOGGER.exception("Extras for webrtc are not installed")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if needs_silero(args):
|
if needs_silero(args):
|
||||||
try:
|
try:
|
||||||
import pysilero_vad # noqa: F401
|
import pysilero_vad # noqa: F401
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_LOGGER.fatal("Install extras for silerovad")
|
_LOGGER.exception("Extras for silerovad are not installed")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
if args.awake_wav and (not Path(args.awake_wav).is_file()):
|
||||||
@@ -263,10 +326,16 @@ async def main() -> None:
|
|||||||
_LOGGER.fatal("%s does not exist", args.done_wav)
|
_LOGGER.fatal("%s does not exist", args.done_wav)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
|
||||||
|
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if args.vad and (args.wake_uri or args.wake_command):
|
if args.vad and (args.wake_uri or args.wake_command):
|
||||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
_LOGGER.warning("VAD is not used with local wake word detection")
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
||||||
|
)
|
||||||
_LOGGER.debug(args)
|
_LOGGER.debug(args)
|
||||||
|
|
||||||
if args.debug_recording_dir:
|
if args.debug_recording_dir:
|
||||||
@@ -280,6 +349,7 @@ async def main() -> None:
|
|||||||
description=args.name,
|
description=args.name,
|
||||||
attribution=Attribution(name="", url=""),
|
attribution=Attribution(name="", url=""),
|
||||||
installed=True,
|
installed=True,
|
||||||
|
version=__version__,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,6 +364,9 @@ async def main() -> None:
|
|||||||
volume_multiplier=args.mic_volume_multiplier,
|
volume_multiplier=args.mic_volume_multiplier,
|
||||||
auto_gain=args.mic_auto_gain,
|
auto_gain=args.mic_auto_gain,
|
||||||
noise_suppression=args.mic_noise_suppression,
|
noise_suppression=args.mic_noise_suppression,
|
||||||
|
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
|
||||||
|
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
|
||||||
|
channel_index=args.mic_channel_index,
|
||||||
),
|
),
|
||||||
vad=VadSettings(
|
vad=VadSettings(
|
||||||
enabled=args.vad,
|
enabled=args.vad,
|
||||||
@@ -305,10 +378,14 @@ async def main() -> None:
|
|||||||
wake=WakeSettings(
|
wake=WakeSettings(
|
||||||
uri=args.wake_uri,
|
uri=args.wake_uri,
|
||||||
command=split_command(args.wake_command),
|
command=split_command(args.wake_command),
|
||||||
names=args.wake_word_name,
|
names=[
|
||||||
refractory_seconds=args.wake_refractory_seconds
|
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
|
||||||
if args.wake_refractory_seconds > 0
|
],
|
||||||
else None,
|
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,
|
||||||
@@ -327,6 +404,7 @@ async def main() -> None:
|
|||||||
streaming_stop=split_command(args.streaming_stop_command),
|
streaming_stop=split_command(args.streaming_stop_command),
|
||||||
detect=split_command(args.detect_command),
|
detect=split_command(args.detect_command),
|
||||||
detection=split_command(args.detection_command),
|
detection=split_command(args.detection_command),
|
||||||
|
played=split_command(args.tts_played_command),
|
||||||
transcript=split_command(args.transcript_command),
|
transcript=split_command(args.transcript_command),
|
||||||
stt_start=split_command(args.stt_start_command),
|
stt_start=split_command(args.stt_start_command),
|
||||||
stt_stop=split_command(args.stt_stop_command),
|
stt_stop=split_command(args.stt_stop_command),
|
||||||
@@ -337,6 +415,15 @@ async def main() -> None:
|
|||||||
connected=split_command(args.connected_command),
|
connected=split_command(args.connected_command),
|
||||||
disconnected=split_command(args.disconnected_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,
|
debug_recording_dir=args.debug_recording_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.cli_args = cli_args
|
self.cli_args = cli_args
|
||||||
self.wyoming_info_event = wyoming_info.event()
|
self.wyoming_info = wyoming_info
|
||||||
self.client_id = str(time.monotonic_ns())
|
self.client_id = str(time.monotonic_ns())
|
||||||
self.satellite = satellite
|
self.satellite = satellite
|
||||||
|
|
||||||
@@ -35,7 +35,8 @@ class SatelliteEventHandler(AsyncEventHandler):
|
|||||||
async def handle_event(self, event: Event) -> bool:
|
async def handle_event(self, event: Event) -> bool:
|
||||||
"""Handle events from the server."""
|
"""Handle events from the server."""
|
||||||
if Describe.is_type(event.type):
|
if Describe.is_type(event.type):
|
||||||
await self.write_event(self.wyoming_info_event)
|
await self.satellite.update_info(self.wyoming_info)
|
||||||
|
await self.write_event(self.wyoming_info.event())
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if self.satellite.server_id is None:
|
if self.satellite.server_id is None:
|
||||||
|
|||||||
+238
-37
@@ -1,36 +1,47 @@
|
|||||||
"""Satellite code."""
|
"""Satellite code."""
|
||||||
|
import array
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
import wave
|
import wave
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Dict, Final, Optional, Set, Union
|
from typing import Callable, Dict, Final, List, Optional, Set, Union
|
||||||
|
|
||||||
from pyring_buffer import RingBuffer
|
from pyring_buffer import RingBuffer
|
||||||
from wyoming.asr import Transcript
|
from wyoming.asr import Transcript
|
||||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
|
||||||
from wyoming.client import AsyncClient
|
from wyoming.client import AsyncClient
|
||||||
from wyoming.error import Error
|
from wyoming.error import Error
|
||||||
from wyoming.event import Event, async_write_event
|
from wyoming.event import Event, async_write_event
|
||||||
|
from wyoming.info import Describe, Info
|
||||||
from wyoming.mic import MicProcessAsyncClient
|
from wyoming.mic import MicProcessAsyncClient
|
||||||
from wyoming.ping import Ping, Pong
|
from wyoming.ping import Ping, Pong
|
||||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||||
from wyoming.satellite import (
|
from wyoming.satellite import (
|
||||||
|
PauseSatellite,
|
||||||
RunSatellite,
|
RunSatellite,
|
||||||
SatelliteConnected,
|
SatelliteConnected,
|
||||||
SatelliteDisconnected,
|
SatelliteDisconnected,
|
||||||
StreamingStarted,
|
StreamingStarted,
|
||||||
StreamingStopped,
|
StreamingStopped,
|
||||||
)
|
)
|
||||||
from wyoming.snd import SndProcessAsyncClient
|
from wyoming.snd import Played, SndProcessAsyncClient
|
||||||
|
from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated
|
||||||
from wyoming.tts import Synthesize
|
from wyoming.tts import Synthesize
|
||||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||||
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
||||||
|
|
||||||
from .settings import SatelliteSettings
|
from .settings import SatelliteSettings
|
||||||
from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events
|
from .utils import (
|
||||||
|
DebugAudioWriter,
|
||||||
|
multiply_volume,
|
||||||
|
normalize_wake_word,
|
||||||
|
run_event_command,
|
||||||
|
wav_to_events,
|
||||||
|
)
|
||||||
from .vad import SileroVad
|
from .vad import SileroVad
|
||||||
from .webrtc import WebRtcAudio
|
from .webrtc import WebRtcAudio
|
||||||
|
|
||||||
@@ -38,6 +49,7 @@ _LOGGER = logging.getLogger()
|
|||||||
|
|
||||||
_PONG_TIMEOUT: Final = 5
|
_PONG_TIMEOUT: Final = 5
|
||||||
_PING_SEND_DELAY: Final = 2
|
_PING_SEND_DELAY: Final = 2
|
||||||
|
_WAKE_INFO_TIMEOUT: Final = 2
|
||||||
|
|
||||||
|
|
||||||
class State(Enum):
|
class State(Enum):
|
||||||
@@ -49,6 +61,12 @@ class State(Enum):
|
|||||||
STOPPED = auto()
|
STOPPED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SoundEvent:
|
||||||
|
event: Event
|
||||||
|
is_tts: bool
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +83,7 @@ class SatelliteBase:
|
|||||||
self._mic_task: Optional[asyncio.Task] = None
|
self._mic_task: Optional[asyncio.Task] = None
|
||||||
self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None
|
self._mic_webrtc: Optional[Callable[[bytes], bytes]] = None
|
||||||
self._snd_task: Optional[asyncio.Task] = None
|
self._snd_task: Optional[asyncio.Task] = None
|
||||||
self._snd_queue: "Optional[asyncio.Queue[Event]]" = None
|
self._snd_queue: "Optional[asyncio.Queue[SoundEvent]]" = None
|
||||||
self._wake_task: Optional[asyncio.Task] = None
|
self._wake_task: Optional[asyncio.Task] = None
|
||||||
self._wake_queue: "Optional[asyncio.Queue[Event]]" = None
|
self._wake_queue: "Optional[asyncio.Queue[Event]]" = None
|
||||||
self._event_task: Optional[asyncio.Task] = None
|
self._event_task: Optional[asyncio.Task] = None
|
||||||
@@ -73,7 +91,7 @@ class SatelliteBase:
|
|||||||
|
|
||||||
self._ping_server_enabled: bool = False
|
self._ping_server_enabled: bool = False
|
||||||
self._pong_received_event = asyncio.Event()
|
self._pong_received_event = asyncio.Event()
|
||||||
self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping")
|
self._ping_server_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
self.microphone_muted = False
|
self.microphone_muted = False
|
||||||
self._unmute_microphone_task: Optional[asyncio.Task] = None
|
self._unmute_microphone_task: Optional[asyncio.Task] = None
|
||||||
@@ -149,6 +167,8 @@ class SatelliteBase:
|
|||||||
"""Remove writer."""
|
"""Remove writer."""
|
||||||
self.server_id = None
|
self.server_id = None
|
||||||
self._writer = None
|
self._writer = None
|
||||||
|
self._disable_ping()
|
||||||
|
|
||||||
_LOGGER.debug("Server disconnected")
|
_LOGGER.debug("Server disconnected")
|
||||||
await self.trigger_server_disonnected()
|
await self.trigger_server_disonnected()
|
||||||
|
|
||||||
@@ -167,6 +187,16 @@ class SatelliteBase:
|
|||||||
else:
|
else:
|
||||||
_LOGGER.exception("Unexpected error sending event to server")
|
_LOGGER.exception("Unexpected error sending event to server")
|
||||||
|
|
||||||
|
def _enable_ping(self) -> None:
|
||||||
|
self._ping_server_enabled = True
|
||||||
|
self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping")
|
||||||
|
|
||||||
|
def _disable_ping(self) -> None:
|
||||||
|
self._ping_server_enabled = False
|
||||||
|
if self._ping_server_task is not None:
|
||||||
|
self._ping_server_task.cancel()
|
||||||
|
self._ping_server_task = None
|
||||||
|
|
||||||
async def _ping_server(self) -> None:
|
async def _ping_server(self) -> None:
|
||||||
try:
|
try:
|
||||||
while self.is_running:
|
while self.is_running:
|
||||||
@@ -189,6 +219,8 @@ class SatelliteBase:
|
|||||||
|
|
||||||
_LOGGER.warning("Did not receive ping response within timeout")
|
_LOGGER.warning("Did not receive ping response within timeout")
|
||||||
await self.clear_server()
|
await self.clear_server()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("Unexpected error in ping server task")
|
_LOGGER.exception("Unexpected error in ping server task")
|
||||||
|
|
||||||
@@ -210,6 +242,7 @@ class SatelliteBase:
|
|||||||
self._writer = None
|
self._writer = None
|
||||||
|
|
||||||
await self._disconnect_from_services()
|
await self._disconnect_from_services()
|
||||||
|
self._disable_ping()
|
||||||
self.state = State.STOPPED
|
self.state = State.STOPPED
|
||||||
|
|
||||||
async def stopped(self) -> None:
|
async def stopped(self) -> None:
|
||||||
@@ -217,19 +250,27 @@ class SatelliteBase:
|
|||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
"""Called when an event is received from the server."""
|
"""Called when an event is received from the server."""
|
||||||
|
forward_event = True
|
||||||
|
|
||||||
if Ping.is_type(event.type):
|
if Ping.is_type(event.type):
|
||||||
# Respond with pong
|
# Respond with pong
|
||||||
ping = Ping.from_event(event)
|
ping = Ping.from_event(event)
|
||||||
await self.event_to_server(Pong(text=ping.text).event())
|
await self.event_to_server(Pong(text=ping.text).event())
|
||||||
|
|
||||||
# Enable pinging
|
if not self._ping_server_enabled:
|
||||||
self._ping_server_enabled = True
|
# Enable pinging
|
||||||
|
self._enable_ping()
|
||||||
|
_LOGGER.debug("Ping enabled")
|
||||||
|
|
||||||
|
forward_event = False
|
||||||
elif Pong.is_type(event.type):
|
elif Pong.is_type(event.type):
|
||||||
# Response from our ping
|
# Response from our ping
|
||||||
self._pong_received_event.set()
|
self._pong_received_event.set()
|
||||||
|
forward_event = False
|
||||||
elif AudioChunk.is_type(event.type):
|
elif AudioChunk.is_type(event.type):
|
||||||
# TTS audio
|
# TTS audio
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event)
|
||||||
|
forward_event = False
|
||||||
elif AudioStart.is_type(event.type):
|
elif AudioStart.is_type(event.type):
|
||||||
# TTS started
|
# TTS started
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event)
|
||||||
@@ -262,12 +303,24 @@ class SatelliteBase:
|
|||||||
elif Error.is_type(event.type):
|
elif Error.is_type(event.type):
|
||||||
_LOGGER.warning(event)
|
_LOGGER.warning(event)
|
||||||
await self.trigger_error(Error.from_event(event))
|
await self.trigger_error(Error.from_event(event))
|
||||||
|
elif TimerStarted.is_type(event.type):
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
await self.trigger_timer_started(TimerStarted.from_event(event))
|
||||||
|
elif TimerUpdated.is_type(event.type):
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
await self.trigger_timer_updated(TimerUpdated.from_event(event))
|
||||||
|
elif TimerCancelled.is_type(event.type):
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
await self.trigger_timer_cancelled(TimerCancelled.from_event(event))
|
||||||
|
elif TimerFinished.is_type(event.type):
|
||||||
|
_LOGGER.debug(event)
|
||||||
|
await self.trigger_timer_finished(TimerFinished.from_event(event))
|
||||||
|
|
||||||
# Forward everything except audio to event service
|
# Forward everything except audio/ping/pong to event service
|
||||||
if not AudioChunk.is_type(event.type):
|
if forward_event:
|
||||||
await self.forward_event(event)
|
await self.forward_event(event)
|
||||||
|
|
||||||
async def _send_run_pipeline(self) -> None:
|
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
|
||||||
"""Sends a RunPipeline event with the correct stages."""
|
"""Sends a RunPipeline event with the correct stages."""
|
||||||
if self.settings.wake.enabled:
|
if self.settings.wake.enabled:
|
||||||
# Local wake word detection
|
# Local wake word detection
|
||||||
@@ -286,8 +339,17 @@ class SatelliteBase:
|
|||||||
end_stage = PipelineStage.HANDLE
|
end_stage = PipelineStage.HANDLE
|
||||||
|
|
||||||
run_pipeline = RunPipeline(
|
run_pipeline = RunPipeline(
|
||||||
start_stage=start_stage, end_stage=end_stage, restart_on_end=restart_on_end
|
start_stage=start_stage,
|
||||||
|
end_stage=end_stage,
|
||||||
|
name=pipeline_name,
|
||||||
|
restart_on_end=restart_on_end,
|
||||||
|
snd_format=AudioFormat(
|
||||||
|
rate=self.settings.snd.rate,
|
||||||
|
width=self.settings.snd.width,
|
||||||
|
channels=self.settings.snd.channels,
|
||||||
|
),
|
||||||
).event()
|
).event()
|
||||||
|
_LOGGER.debug(run_pipeline)
|
||||||
await self.event_to_server(run_pipeline)
|
await self.event_to_server(run_pipeline)
|
||||||
await self.forward_event(run_pipeline)
|
await self.forward_event(run_pipeline)
|
||||||
|
|
||||||
@@ -425,15 +487,36 @@ class SatelliteBase:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Audio processing
|
# Audio processing
|
||||||
if self.settings.mic.needs_processing and AudioChunk.is_type(
|
if (
|
||||||
event.type
|
self.settings.mic.needs_processing
|
||||||
|
or (self.settings.mic.channel_index is not None)
|
||||||
|
and AudioChunk.is_type(event.type)
|
||||||
):
|
):
|
||||||
chunk = AudioChunk.from_event(event)
|
chunk = AudioChunk.from_event(event)
|
||||||
audio_bytes = self._process_mic_audio(chunk.audio)
|
if self.settings.mic.channel_index is not None:
|
||||||
|
if chunk.width != 2:
|
||||||
|
raise ValueError(
|
||||||
|
"Mic channel index selection requires 16-bit samples"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to unsigned 16-bit array to make channel extraction easier
|
||||||
|
audio_array = array.array("H", chunk.audio)
|
||||||
|
audio_bytes = audio_array[
|
||||||
|
self.settings.mic.channel_index :: chunk.channels
|
||||||
|
].tobytes()
|
||||||
|
|
||||||
|
if self.settings.mic.needs_processing:
|
||||||
|
if audio_bytes is None:
|
||||||
|
audio_bytes = chunk.audio
|
||||||
|
|
||||||
|
audio_bytes = self._process_mic_audio(chunk.audio)
|
||||||
|
|
||||||
event = AudioChunk(
|
event = AudioChunk(
|
||||||
rate=chunk.rate,
|
rate=chunk.rate,
|
||||||
width=chunk.width,
|
width=chunk.width,
|
||||||
channels=chunk.channels,
|
channels=chunk.channels
|
||||||
|
if (self.settings.mic.channel_index is None)
|
||||||
|
else 1,
|
||||||
audio=audio_bytes,
|
audio=audio_bytes,
|
||||||
).event()
|
).event()
|
||||||
else:
|
else:
|
||||||
@@ -467,10 +550,10 @@ class SatelliteBase:
|
|||||||
# Sound
|
# Sound
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def event_to_snd(self, event: Event) -> None:
|
async def event_to_snd(self, event: Event, is_tts: bool = True) -> None:
|
||||||
"""Send an event to the sound service."""
|
"""Send an event to the sound service."""
|
||||||
if self._snd_queue is not None:
|
if self._snd_queue is not None:
|
||||||
self._snd_queue.put_nowait(event)
|
self._snd_queue.put_nowait(SoundEvent(event, is_tts))
|
||||||
|
|
||||||
def _make_snd_client(self) -> Optional[AsyncClient]:
|
def _make_snd_client(self) -> Optional[AsyncClient]:
|
||||||
"""Create client for snd service."""
|
"""Create client for snd service."""
|
||||||
@@ -505,7 +588,8 @@ class SatelliteBase:
|
|||||||
if self._snd_queue is None:
|
if self._snd_queue is None:
|
||||||
self._snd_queue = asyncio.Queue()
|
self._snd_queue = asyncio.Queue()
|
||||||
|
|
||||||
event = await self._snd_queue.get()
|
snd_event = await self._snd_queue.get()
|
||||||
|
event = snd_event.event
|
||||||
|
|
||||||
if snd_client is None:
|
if snd_client is None:
|
||||||
snd_client = self._make_snd_client()
|
snd_client = self._make_snd_client()
|
||||||
@@ -532,6 +616,8 @@ class SatelliteBase:
|
|||||||
event.type
|
event.type
|
||||||
):
|
):
|
||||||
await _disconnect()
|
await _disconnect()
|
||||||
|
if snd_event.is_tts:
|
||||||
|
await self.trigger_played()
|
||||||
snd_client = None # reconnect on next event
|
snd_client = None # reconnect on next event
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
@@ -575,9 +661,8 @@ class SatelliteBase:
|
|||||||
for event in wav_to_events(
|
for event in wav_to_events(
|
||||||
wav_path,
|
wav_path,
|
||||||
samples_per_chunk=self.settings.snd.samples_per_chunk,
|
samples_per_chunk=self.settings.snd.samples_per_chunk,
|
||||||
volume_multiplier=self.settings.snd.volume_multiplier,
|
|
||||||
):
|
):
|
||||||
await self.event_to_snd(event)
|
await self.event_to_snd(event, is_tts=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Unmute in case of an error
|
# Unmute in case of an error
|
||||||
self.microphone_muted = False
|
self.microphone_muted = False
|
||||||
@@ -715,7 +800,11 @@ class SatelliteBase:
|
|||||||
|
|
||||||
async def _send_wake_detect(self) -> None:
|
async def _send_wake_detect(self) -> None:
|
||||||
"""Inform wake word service of which wake words to detect."""
|
"""Inform wake word service of which wake words to detect."""
|
||||||
await self.event_to_wake(Detect(names=self.settings.wake.names).event())
|
wake_names: Optional[List[str]] = None
|
||||||
|
if self.settings.wake.names:
|
||||||
|
wake_names = [w.name for w in self.settings.wake.names]
|
||||||
|
|
||||||
|
await self.event_to_wake(Detect(names=wake_names).event())
|
||||||
await self.trigger_detect()
|
await self.trigger_detect()
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
@@ -756,6 +845,11 @@ class SatelliteBase:
|
|||||||
mute_microphone=self.settings.mic.mute_during_awake_wav,
|
mute_microphone=self.settings.mic.mute_during_awake_wav,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def trigger_played(self) -> None:
|
||||||
|
"""Called when audio stopped playing"""
|
||||||
|
await run_event_command(self.settings.event.played)
|
||||||
|
await self.forward_event(Played().event())
|
||||||
|
|
||||||
async def trigger_transcript(self, transcript: Transcript) -> None:
|
async def trigger_transcript(self, transcript: Transcript) -> None:
|
||||||
"""Called when speech-to-text text is received."""
|
"""Called when speech-to-text text is received."""
|
||||||
await run_event_command(self.settings.event.transcript, transcript.text)
|
await run_event_command(self.settings.event.transcript, transcript.text)
|
||||||
@@ -785,6 +879,28 @@ class SatelliteBase:
|
|||||||
"""Called when an error occurs on the server."""
|
"""Called when an error occurs on the server."""
|
||||||
await run_event_command(self.settings.event.error, error.text)
|
await run_event_command(self.settings.event.error, error.text)
|
||||||
|
|
||||||
|
async def trigger_timer_started(self, timer_started: TimerStarted) -> None:
|
||||||
|
"""Called when timer-started event is received."""
|
||||||
|
await run_event_command(self.settings.timer.started, timer_started)
|
||||||
|
|
||||||
|
async def trigger_timer_updated(self, timer_updated: TimerUpdated) -> None:
|
||||||
|
"""Called when timer-updated event is received."""
|
||||||
|
await run_event_command(self.settings.timer.updated, timer_updated)
|
||||||
|
|
||||||
|
async def trigger_timer_cancelled(self, timer_cancelled: TimerCancelled) -> None:
|
||||||
|
"""Called when timer-cancelled event is received."""
|
||||||
|
await run_event_command(self.settings.timer.cancelled, timer_cancelled.id)
|
||||||
|
|
||||||
|
async def trigger_timer_finished(self, timer_finished: TimerFinished) -> None:
|
||||||
|
"""Called when timer-finished event is received."""
|
||||||
|
await run_event_command(self.settings.timer.finished, timer_finished.id)
|
||||||
|
for _ in range(self.settings.timer.finished_wav_plays):
|
||||||
|
await self._play_wav(
|
||||||
|
self.settings.timer.finished_wav,
|
||||||
|
mute_microphone=self.settings.mic.mute_during_awake_wav,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self.settings.timer.finished_wav_delay)
|
||||||
|
|
||||||
async def forward_event(self, event: Event) -> None:
|
async def forward_event(self, event: Event) -> None:
|
||||||
"""Forward an event to the event service."""
|
"""Forward an event to the event service."""
|
||||||
if self._event_queue is not None:
|
if self._event_queue is not None:
|
||||||
@@ -833,6 +949,13 @@ class SatelliteBase:
|
|||||||
|
|
||||||
await _disconnect()
|
await _disconnect()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Info
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def update_info(self, info: Info) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -858,6 +981,9 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
|||||||
_LOGGER.info("Streaming audio")
|
_LOGGER.info("Streaming audio")
|
||||||
await self._send_run_pipeline()
|
await self._send_run_pipeline()
|
||||||
await self.trigger_streaming_start()
|
await self.trigger_streaming_start()
|
||||||
|
elif PauseSatellite.is_type(event.type):
|
||||||
|
self.is_streaming = False
|
||||||
|
_LOGGER.info("Satellite paused")
|
||||||
elif Detection.is_type(event.type):
|
elif Detection.is_type(event.type):
|
||||||
# Start debug recording
|
# Start debug recording
|
||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
@@ -925,16 +1051,27 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
if settings.wake.enabled:
|
if settings.wake.enabled:
|
||||||
_LOGGER.warning("Local wake word detection is enabled but will not be used")
|
_LOGGER.warning("Local wake word detection is enabled but will not be used")
|
||||||
|
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
await super().event_from_server(event)
|
await super().event_from_server(event)
|
||||||
|
|
||||||
if RunSatellite.is_type(event.type):
|
if RunSatellite.is_type(event.type):
|
||||||
|
self._is_paused = False
|
||||||
_LOGGER.info("Waiting for speech")
|
_LOGGER.info("Waiting for speech")
|
||||||
elif Detection.is_type(event.type):
|
elif Detection.is_type(event.type):
|
||||||
# Start debug recording
|
# Start debug recording
|
||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
self.stt_audio_writer.start()
|
self.stt_audio_writer.start()
|
||||||
elif Transcript.is_type(event.type) or Error.is_type(event.type):
|
elif (
|
||||||
|
Transcript.is_type(event.type)
|
||||||
|
or Error.is_type(event.type)
|
||||||
|
or PauseSatellite.is_type(event.type)
|
||||||
|
):
|
||||||
|
if PauseSatellite.is_type(event.type):
|
||||||
|
self._is_paused = True
|
||||||
|
_LOGGER.debug("Satellite paused")
|
||||||
|
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
|
|
||||||
# Stop debug recording
|
# Stop debug recording
|
||||||
@@ -944,7 +1081,11 @@ class VadStreamingSatellite(SatelliteBase):
|
|||||||
async def event_from_mic(
|
async def event_from_mic(
|
||||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
|
if (
|
||||||
|
(not AudioChunk.is_type(event.type))
|
||||||
|
or self.microphone_muted
|
||||||
|
or self._is_paused
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only unpack chunk once
|
# Only unpack chunk once
|
||||||
@@ -1065,20 +1206,30 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
# same timestamp.
|
# same timestamp.
|
||||||
self._debug_recording_timestamp: Optional[int] = None
|
self._debug_recording_timestamp: Optional[int] = None
|
||||||
|
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
|
self._wake_info: Optional[Info] = None
|
||||||
|
self._wake_info_ready = asyncio.Event()
|
||||||
|
|
||||||
async def event_from_server(self, event: Event) -> None:
|
async def event_from_server(self, event: Event) -> None:
|
||||||
# Only check event types once
|
# Only check event types once
|
||||||
is_run_satellite = False
|
is_run_satellite = False
|
||||||
|
is_pause_satellite = False
|
||||||
is_transcript = False
|
is_transcript = False
|
||||||
is_error = False
|
is_error = False
|
||||||
|
|
||||||
if RunSatellite.is_type(event.type):
|
if RunSatellite.is_type(event.type):
|
||||||
is_run_satellite = True
|
is_run_satellite = True
|
||||||
|
self._is_paused = False
|
||||||
|
|
||||||
|
elif PauseSatellite.is_type(event.type):
|
||||||
|
is_pause_satellite = True
|
||||||
elif Transcript.is_type(event.type):
|
elif Transcript.is_type(event.type):
|
||||||
is_transcript = True
|
is_transcript = True
|
||||||
elif Error.is_type(event.type):
|
elif Error.is_type(event.type):
|
||||||
is_error = True
|
is_error = True
|
||||||
|
|
||||||
if is_transcript:
|
if is_transcript or is_pause_satellite:
|
||||||
# Stop streaming before event_from_server is called because it will
|
# Stop streaming before event_from_server is called because it will
|
||||||
# play the "done" WAV.
|
# play the "done" WAV.
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
@@ -1089,17 +1240,28 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
|
|
||||||
await super().event_from_server(event)
|
await super().event_from_server(event)
|
||||||
|
|
||||||
if is_run_satellite or is_transcript or is_error:
|
if is_run_satellite or is_transcript or is_error or is_pause_satellite:
|
||||||
# Stop streaming and go back to wake word detection
|
# Stop streaming
|
||||||
self.is_streaming = False
|
self.is_streaming = False
|
||||||
await self.trigger_streaming_stop()
|
|
||||||
await self._send_wake_detect()
|
|
||||||
_LOGGER.info("Waiting for wake word")
|
|
||||||
|
|
||||||
# Start debug recording (wake)
|
if is_pause_satellite:
|
||||||
self._debug_recording_timestamp = time.monotonic_ns()
|
self._is_paused = True
|
||||||
if self.wake_audio_writer is not None:
|
_LOGGER.debug("Satellite is paused")
|
||||||
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
else:
|
||||||
|
# Go back to wake word detection
|
||||||
|
await self.trigger_streaming_stop()
|
||||||
|
|
||||||
|
# It's possible to be paused in the middle of streaming
|
||||||
|
if not self._is_paused:
|
||||||
|
await self._send_wake_detect()
|
||||||
|
_LOGGER.info("Waiting for wake word")
|
||||||
|
|
||||||
|
# Start debug recording (wake)
|
||||||
|
self._debug_recording_timestamp = time.monotonic_ns()
|
||||||
|
if self.wake_audio_writer is not None:
|
||||||
|
self.wake_audio_writer.start(
|
||||||
|
timestamp=self._debug_recording_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
async def trigger_server_disonnected(self) -> None:
|
async def trigger_server_disonnected(self) -> None:
|
||||||
await super().trigger_server_disonnected()
|
await super().trigger_server_disonnected()
|
||||||
@@ -1115,7 +1277,11 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
async def event_from_mic(
|
async def event_from_mic(
|
||||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
|
if (
|
||||||
|
(not AudioChunk.is_type(event.type))
|
||||||
|
or self.microphone_muted
|
||||||
|
or self._is_paused
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Debug audio recording
|
# Debug audio recording
|
||||||
@@ -1138,8 +1304,13 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
await self.event_to_wake(event)
|
await self.event_to_wake(event)
|
||||||
|
|
||||||
async def event_from_wake(self, event: Event) -> None:
|
async def event_from_wake(self, event: Event) -> None:
|
||||||
|
if Info.is_type(event.type):
|
||||||
|
self._wake_info = Info.from_event(event)
|
||||||
|
self._wake_info_ready.set()
|
||||||
|
return
|
||||||
|
|
||||||
if self.is_streaming or (self.server_id is None):
|
if self.is_streaming or (self.server_id is None):
|
||||||
# Not streaming or no server connected
|
# Not detecting or no server connected
|
||||||
return
|
return
|
||||||
|
|
||||||
if Detection.is_type(event.type):
|
if Detection.is_type(event.type):
|
||||||
@@ -1161,6 +1332,8 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
if self.stt_audio_writer is not None:
|
if self.stt_audio_writer is not None:
|
||||||
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
||||||
|
|
||||||
|
_LOGGER.debug(detection)
|
||||||
|
|
||||||
self.is_streaming = True
|
self.is_streaming = True
|
||||||
_LOGGER.debug("Streaming audio")
|
_LOGGER.debug("Streaming audio")
|
||||||
|
|
||||||
@@ -1174,7 +1347,35 @@ class WakeStreamingSatellite(SatelliteBase):
|
|||||||
# No refractory period
|
# No refractory period
|
||||||
self.refractory_timestamp.pop(detection.name, None)
|
self.refractory_timestamp.pop(detection.name, None)
|
||||||
|
|
||||||
await self._send_run_pipeline()
|
# Forward to the server
|
||||||
|
await self.event_to_server(event)
|
||||||
|
|
||||||
|
# Match detected wake word name with pipeline name
|
||||||
|
pipeline_name: Optional[str] = None
|
||||||
|
if self.settings.wake.names:
|
||||||
|
detection_name = normalize_wake_word(detection.name)
|
||||||
|
for wake_name in self.settings.wake.names:
|
||||||
|
if normalize_wake_word(wake_name.name) == detection_name:
|
||||||
|
pipeline_name = wake_name.pipeline
|
||||||
|
break
|
||||||
|
|
||||||
|
await self._send_run_pipeline(pipeline_name=pipeline_name)
|
||||||
await self.forward_event(event) # forward to event service
|
await self.forward_event(event) # forward to event service
|
||||||
await self.trigger_detection(Detection.from_event(event))
|
await self.trigger_detection(Detection.from_event(event))
|
||||||
await self.trigger_streaming_start()
|
await self.trigger_streaming_start()
|
||||||
|
|
||||||
|
async def update_info(self, info: Info) -> None:
|
||||||
|
self._wake_info = None
|
||||||
|
self._wake_info_ready.clear()
|
||||||
|
await self.event_to_wake(Describe().event())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._wake_info_ready.wait(), timeout=_WAKE_INFO_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._wake_info is not None:
|
||||||
|
# Update wake info only
|
||||||
|
info.wake = self._wake_info.wake
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.warning("Failed to get info from wake service")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Satellite settings."""
|
"""Satellite settings."""
|
||||||
|
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -55,6 +56,9 @@ class MicSettings(ServiceSettings):
|
|||||||
seconds_to_mute_after_awake_wav: float = 0.5
|
seconds_to_mute_after_awake_wav: float = 0.5
|
||||||
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
|
"""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."""
|
||||||
@@ -100,11 +104,19 @@ class SndSettings(ServiceSettings):
|
|||||||
return self.enabled and (self.volume_multiplier != 1.0)
|
return self.enabled and (self.volume_multiplier != 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WakeWordAndPipeline:
|
||||||
|
"""Wake word name + optional pipeline name."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
pipeline: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WakeSettings(ServiceSettings):
|
class WakeSettings(ServiceSettings):
|
||||||
"""Wake word service settings."""
|
"""Wake word service settings."""
|
||||||
|
|
||||||
names: Optional[List[str]] = None
|
names: Optional[List[WakeWordAndPipeline]] = None
|
||||||
"""List of wake word names to listen for."""
|
"""List of wake word names to listen for."""
|
||||||
|
|
||||||
rate: int = 16000
|
rate: int = 16000
|
||||||
@@ -149,6 +161,7 @@ class EventSettings(ServiceSettings):
|
|||||||
streaming_stop: Optional[List[str]] = None
|
streaming_stop: Optional[List[str]] = None
|
||||||
detect: Optional[List[str]] = None
|
detect: Optional[List[str]] = None
|
||||||
detection: Optional[List[str]] = None
|
detection: Optional[List[str]] = None
|
||||||
|
played: Optional[List[str]] = None
|
||||||
transcript: Optional[List[str]] = None
|
transcript: Optional[List[str]] = None
|
||||||
stt_start: Optional[List[str]] = None
|
stt_start: Optional[List[str]] = None
|
||||||
stt_stop: Optional[List[str]] = None
|
stt_stop: Optional[List[str]] = None
|
||||||
@@ -160,6 +173,32 @@ class EventSettings(ServiceSettings):
|
|||||||
disconnected: 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)
|
||||||
class SatelliteSettings:
|
class SatelliteSettings:
|
||||||
"""Wyoming satellite settings."""
|
"""Wyoming satellite settings."""
|
||||||
@@ -169,6 +208,7 @@ class SatelliteSettings:
|
|||||||
wake: WakeSettings = field(default_factory=WakeSettings)
|
wake: WakeSettings = field(default_factory=WakeSettings)
|
||||||
snd: SndSettings = field(default_factory=SndSettings)
|
snd: SndSettings = field(default_factory=SndSettings)
|
||||||
event: EventSettings = field(default_factory=EventSettings)
|
event: EventSettings = field(default_factory=EventSettings)
|
||||||
|
timer: TimerSettings = field(default_factory=TimerSettings)
|
||||||
|
|
||||||
restart_timeout: float = 5.0
|
restart_timeout: float = 5.0
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .misc import (
|
|||||||
get_mac_address,
|
get_mac_address,
|
||||||
needs_silero,
|
needs_silero,
|
||||||
needs_webrtc,
|
needs_webrtc,
|
||||||
|
normalize_wake_word,
|
||||||
run_event_command,
|
run_event_command,
|
||||||
split_command,
|
split_command,
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"multiply_volume",
|
"multiply_volume",
|
||||||
"needs_silero",
|
"needs_silero",
|
||||||
"needs_webrtc",
|
"needs_webrtc",
|
||||||
|
"normalize_wake_word",
|
||||||
"run_event_command",
|
"run_event_command",
|
||||||
"split_command",
|
"split_command",
|
||||||
"wav_to_events",
|
"wav_to_events",
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
"""Miscellaneous utilities."""
|
"""Miscellaneous utilities."""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List, Optional
|
from functools import lru_cache
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from wyoming.event import Eventable
|
||||||
|
|
||||||
_LOGGER = logging.getLogger()
|
_LOGGER = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
async def run_event_command(
|
async def run_event_command(
|
||||||
command: Optional[List[str]], command_input: Optional[str] = None
|
command: Optional[List[str]], command_input: Optional[Union[str, Eventable]] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run a custom event command with optional input."""
|
"""Run a custom event command with optional input."""
|
||||||
if not command:
|
if not command:
|
||||||
return
|
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)
|
_LOGGER.debug("Running %s", command)
|
||||||
program, *program_args = command
|
program, *program_args = command
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
@@ -56,3 +67,24 @@ def split_command(command: Optional[str]) -> Optional[List[str]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return shlex.split(command)
|
return shlex.split(command)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def normalize_wake_word(wake_word: str) -> str:
|
||||||
|
"""Normalizes a wake word name for comparison."""
|
||||||
|
|
||||||
|
# Lower case
|
||||||
|
wake_word = wake_word.strip().casefold()
|
||||||
|
|
||||||
|
# Remove version numbers like v1.0
|
||||||
|
wake_word = re.sub(r"v[0-9]+(\.[0-9])+", "", wake_word)
|
||||||
|
|
||||||
|
# Replace anything besides letters/numbers with whitespace
|
||||||
|
wake_word = "".join(
|
||||||
|
c if unicodedata.category(c) in ("Ll", "Nd") else " " for c in wake_word
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize whitespace
|
||||||
|
wake_word = " ".join(wake_word.strip().split())
|
||||||
|
|
||||||
|
return wake_word
|
||||||
|
|||||||
Reference in New Issue
Block a user