64 Commits
Author SHA1 Message Date
Michael Hansen b2409a4a40 Move other requirements to pyproject.toml 2025-01-24 09:42:20 -06:00
Michael Hansen 2c72ad0e01 Migrate to pyproject.toml 2025-01-24 09:28:51 -06:00
Michael Hansen 3576c0fc5f Lint 2025-01-24 09:28:38 -06:00
Michael Hansen 1915d3fce3 Send audio-start to wake service after detect 2025-01-24 09:17:54 -06:00
Michael Hansen a2bb7c8f57 Add run_usbmic script 2024-07-01 11:33:06 -05:00
Michael Hansen cc9b33e8b8 Merge pull request #126 from tobuh/respeaker-usb-4mic
Add example for ReSpeaker USB 4mic array v2.0
2024-07-01 11:32:01 -05:00
Michael Hansen 862d102405 Merge pull request #167 from tannisroot/docs_change
Change docs to suggest bookworm based Pi OS
2024-07-01 11:23:00 -05:00
Michael Hansen 65c123ffb9 Merge pull request #177 from thetic/master
Improve import error logging
2024-07-01 11:22:38 -05:00
Michael Hansen 6d11883dcb Restrict range 2024-07-01 11:21:52 -05:00
Michael Hansen 2f4276e180 Merge pull request #101 from llluis/led_brightness
led brightness argument
2024-07-01 11:20:55 -05:00
Michael Hansen 397d4c8b61 Merge pull request #109 from llluis/bugfix-wav-volume
bugfix: Remove double volume multiplier
2024-07-01 11:18:03 -05:00
Michael Hansen f6c0d29405 Merge pull request #171 from tannisroot/adjust_compile_threads
Adjust compile threads for low memory boards when installing Respeaker driver
2024-07-01 09:35:21 -05:00
Aleksandr OleinikovandMichael Hansen a80c7724a6 suggestions by Mike
Co-authored-by: Michael Hansen <hansen.mike@gmail.com>
2024-06-26 21:42:33 +03:00
Michael Hansen 2e046c6b8b Clean up tts played 2024-06-26 13:30:40 -05:00
Michael Hansen 5d3c243a88 Merge pull request #100 from llluis/fix-played-event
bugfix played event
2024-06-26 13:26:42 -05:00
tannisroot 2b394504a0 Adjust compile threads for low memory boards 2024-06-23 02:30:24 +03:00
Chad Condon c6d1dec918 Clarify import error message 2024-06-16 12:26:27 -07:00
Chad Condon 2f6ec1d3b0 Include trace in import failure errors 2024-06-16 12:24:43 -07:00
Michael Hansen 41f107548f Merge pull request #168 from rhasspy/synesthesiam-20240522-timers
Add voice timer support
2024-05-24 15:38:21 -05:00
Michael Hansen 2824b653c5 Update changelog 2024-05-24 15:14:15 -05:00
Michael Hansen 14edd814bb Document timers 2024-05-24 15:14:15 -05:00
Michael Hansen af18c7c0f4 Add websocket service 2024-05-24 15:14:15 -05:00
Michael Hansen 16847ef5e9 Add timers 2024-05-24 15:14:15 -05:00
Michael Hansen 9b35e0334f Merge pull request #165 from tannisroot/driver_installer_fix
Allow to download the respeaker driver for any supported kernel
2024-05-24 15:13:50 -05:00
tannisroot f0297415ed Change docs to suggest bookworm based Pi OS 2024-05-22 09:09:16 +03:00
tannisroot 30e9d74c45 Download the respeaker driver for the kernel version in use 2024-05-18 11:20:51 +03:00
Tobias Buhlinger 69ceeef566 Add example for ReSpeaker USB 4mic array v2.0 2024-02-27 22:14:12 +01:00
Michael Hansen 962b2f5dc6 Use better --version 2024-02-25 09:51:24 -06:00
Michael Hansen 36353ff03a Flesh out test 2024-02-19 14:20:57 -06:00
Luis Andrade 886019e04f Merge branch 'rhasspy:master' into bugfix-wav-volume 2024-02-19 14:45:56 -05:00
Michael Hansen 70652bbea2 Add more tests 2024-02-18 17:27:59 -06:00
Luis Andrade 61ae4ce68c remove double volume 2024-02-12 12:49:34 -05:00
Luis Andrade 00188fa5da led brightness argument 2024-02-08 01:05:42 -05:00
Luis Andrade 8a5f3fb409 bugfix played event 2024-02-08 00:25:53 -05:00
Michael Hansen f0dcf39946 Add --mic-channel-index 2024-02-01 16:55:14 -06:00
Michael Hansen 42325ec284 Update changelog 2024-02-01 15:58:25 -06:00
Michael Hansen 1bc3471637 Allow running a different pipeline depending on wake word 2024-01-30 15:39:50 -06:00
Michael Hansen 8850100ca6 Bump version 2024-01-30 14:21:50 -06:00
Michael Hansen 8e793a185d Change to --mic-no-mute-after-awake-wav 2024-01-30 14:21:28 -06:00
Michael Hansen 12cce8e8f5 Merge pull request #91 from Ranger2959/awake-wav-mute-settings
Awake wav mic mute settings
2024-01-30 14:18:43 -06:00
Michael Hansen fbc129711d Change to --tts-played-command 2024-01-30 14:17:48 -06:00
Michael Hansen 7a45d6b2e9 Merge pull request #94 from llluis/played-command
Expose Played command
2024-01-30 14:14:46 -06:00
Michael Hansen 9e6195baa7 Merge pull request #50 from preimmortal/docker
fix: fix docker build scripts and dockerignore for docker version 24.0.7
2024-01-30 14:13:15 -06:00
Luis Andrade 9fda18770b clarify TTS commands 2024-01-28 23:03:01 -05:00
Luis Andrade 503dc952bb add played to readme 2024-01-28 22:59:07 -05:00
Luis Andrade 366547c457 expose played command 2024-01-28 22:53:37 -05:00
Christopher Gozdziewski 74f22e649c Awake wav mic mute settings 2024-01-26 21:42:31 -06:00
preimmortal 865b40722b Merge branch 'master' into docker 2024-01-21 11:47:46 -08:00
Michael Hansen 20bbd3cb4f Merge pull request #72 from llluis/redo-send-played-event
redo the send played event
2024-01-19 23:27:18 -06:00
Michael Hansen 038e9667d2 Merge pull request #73 from andreasbrett/patch-1
Update .dockerignore
2024-01-19 23:24:20 -06:00
Michael Hansen 8b24216e4e Merge pull request #77 from rhasspy/synesthesiam-20241019-log-format
Add --log-format
2024-01-19 17:01:25 -06:00
Michael Hansen 87bfcf4bf7 Add --log-format 2024-01-19 16:58:41 -06:00
Andreas Brett 090b2588b7 Update .dockerignore
VERSION is required within the docker image
2024-01-19 17:59:55 +01:00
Luis Andrade a638600348 redo the send played event 2024-01-19 14:39:03 +00:00
Michael Hansen d1578cb863 Merge pull request #70 from llluis/fix-2mic-tutorial
bump wyoming version in 2mic tutorial
2024-01-19 08:21:08 -06:00
Luis Andrade 086f346119 bump to 1.5.2 2024-01-19 06:44:30 +00:00
Luis Andrade 3b2842d11b undo oups 2024-01-19 04:50:28 +00:00
Luis Andrade 1bceb97177 bump wyoming version in 2mic tutorial 2024-01-19 04:38:57 +00:00
Luis Andrade 71ab73ad73 send played event at the disconnect of client 2024-01-19 04:36:14 +00:00
Michael Hansen 160370be5a Merge pull request #65 from rhasspy/synesthesiam-20240118-wyoming-1.5.2
wyoming 1.5.2
2024-01-18 18:17:21 -06:00
Michael Hansen 594f6f696d Fix requirements 2024-01-18 14:46:57 -06:00
Michael Hansen bd9de77a4c wyoming 1.5.2 2024-01-18 14:46:27 -06:00
Michael Hansen 5088b590a6 wyoming 1.5.1 2024-01-18 11:50:16 -06:00
pre 79dee35659 fix: fix docker build scripts, dockerfile, dockerignore 2024-01-13 01:23:26 -08:00
40 changed files with 1351 additions and 173 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
* *
!requirements.txt !pyproject.toml
!MANIFEST.in
!setup.py
!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
+2
View File
@@ -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
View File
@@ -9,7 +9,7 @@ tmp/
build build
htmlcov htmlcov
/.venv/ .venv/
.mypy_cache/ .mypy_cache/
__pycache__/ __pycache__/
+29
View File
@@ -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
View File
@@ -10,7 +10,7 @@ WORKDIR /app
COPY sounds/ ./sounds/ COPY sounds/ ./sounds/
COPY script/setup ./script/ COPY script/setup ./script/
COPY setup.py requirements.txt MANIFEST.in ./ COPY pyproject.toml ./
COPY wyoming_satellite/ ./wyoming_satellite/ COPY wyoming_satellite/ ./wyoming_satellite/
RUN script/setup RUN script/setup
-1
View File
@@ -1 +0,0 @@
include requirements.txt
+10 -2
View File
@@ -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.
+13 -5
View File
@@ -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:
@@ -55,9 +55,7 @@ python3 -m venv .venv
.venv/bin/pip3 install --upgrade wheel setuptools .venv/bin/pip3 install --upgrade wheel setuptools
.venv/bin/pip3 install \ .venv/bin/pip3 install \
-f 'https://synesthesiam.github.io/prebuilt-apps/' \ -f 'https://synesthesiam.github.io/prebuilt-apps/' \
-r requirements.txt \ -e '.[all]'
-r requirements_audio_enhancement.txt \
-r requirements_vad.txt
``` ```
If the installation was successful, you should be able to run: If the installation was successful, you should be able to run:
@@ -302,9 +300,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 +390,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.
+1 -1
View File
@@ -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:
+17 -4
View File
@@ -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}"
} }
+10 -3
View File
@@ -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
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Controls the LEDs on the ReSpeaker Mic Array v2.0 (USB) ."""
import argparse
import asyncio
import logging
import time
from functools import partial
from pixel_ring import pixel_ring
from wyoming.event import Event
from wyoming.satellite import (
SatelliteConnected,
SatelliteDisconnected,
StreamingStarted,
StreamingStopped,
)
from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.snd import Played
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detection
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
pixel_ring.set_color_palette(0x0080FF, 0x007A37)
pixel_ring.think()
await asyncio.sleep(3)
pixel_ring.off()
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args))
except KeyboardInterrupt:
pass
finally:
pixel_ring.off()
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if Detection.is_type(event.type):
_LOGGER.debug("Detection")
pixel_ring.wakeup()
elif VoiceStarted.is_type(event.type):
_LOGGER.debug("VoiceStarted")
pixel_ring.speak()
elif VoiceStopped.is_type(event.type):
_LOGGER.debug("VoiceStopped")
pixel_ring.spin()
elif StreamingStopped.is_type(event.type):
_LOGGER.debug("StreamingStopped")
pixel_ring.off()
elif SatelliteConnected.is_type(event.type):
_LOGGER.debug("SatelliteConnected")
pixel_ring.think()
await asyncio.sleep(2)
pixel_ring.off()
elif Played.is_type(event.type):
_LOGGER.debug("Played")
pixel_ring.off()
elif SatelliteDisconnected.is_type(event.type):
_LOGGER.debug("SatelliteDisconnected")
pixel_ring.mono(0xFF0000)
return True
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
@@ -0,0 +1 @@
websockets==12.0
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
from functools import partial
from typing import Optional
import websockets
from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger()
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--websocket-host", default="localhost")
parser.add_argument("--websocket-port", type=int, default=8675)
#
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Start server
server = AsyncServer.from_uri(args.uri)
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
try:
async with websockets.serve(
partial(websocket_connected, queue),
args.websocket_host,
args.websocket_port,
):
await server.run(partial(WebsocketEventHandler, args, queue))
finally:
queue.put_nowait(None)
# -----------------------------------------------------------------------------
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
try:
while True:
event = await queue.get()
if event is None:
# Stop signal
break
await websocket.send(
json.dumps(
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
)
)
except websockets.ConnectionClosed:
pass
except Exception:
_LOGGER.exception("Error in websocket handler")
# -----------------------------------------------------------------------------
class WebsocketEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
queue: "asyncio.Queue[Optional[Event]]",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.queue = queue
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
self.queue.put_nowait(event)
return True
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Binary file not shown.
+244
View File
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Wyoming satellite timer example">
<meta name="author" content="Michael Hansen">
<title>Wyoming Timer Example</title>
<style>
body {
background-color: black;
}
.timer-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.timer {
display: flex;
width: 100px;
height: auto;
flex-wrap:wrap;
justify-content: center;
align-items: center;
background-color: #03a9f4;
padding: 15px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
color: white;
margin-right: 20px
}
.time {
font-size: 20px;
}
.message {
text-align: center;
width: 90px;
font-size: 24px;
font-weight: bold;
}
.finished {
background-color: red;
}
.paused {
background-color: yellow;
color: black;
}
.blink {
animation: blink-animation 1s linear infinite;
-webkit-animation: blink-animation 1s linear infinite;
}
@keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
@-webkit-keyframes blink-animation {
0%, 100% {opacity: 1;}
50% {opacity: 0;}
}
</style>
</head>
<body>
<div id="timers" class="timer-container">
</div>
<audio id="audio-finished" hidden controls loop src="timer_finished.wav"></audio>
<script>
let socket = new WebSocket("ws://localhost:8675")
var timers = []
var finished_timers = 0
function q(selector) {return document.querySelector(selector)}
socket.onopen = function(e) {
console.log("Connected")
};
socket.onmessage = function(event) {
wyo_event = JSON.parse(event.data)
if (wyo_event.type == "timer-started") {
let timer_info = wyo_event.data
timer_info.is_active = true
console.log(timer_info)
timers.push(timer_info)
let timers_elem = q("#timers")
let timer_div = document.createElement("div")
timer_div.id = "timer-" + timer_info.id
timer_div.classList.add("timer")
let message_div = document.createElement("div")
message_div.classList.add("message")
if (timer_info.name) {
message_div.innerHTML = timer_info.name
} else {
if (timer_info.start_hours) {
message_div.innerHTML += timer_info.start_hours + " hr "
}
if (timer_info.start_minutes) {
message_div.innerHTML += timer_info.start_minutes + " min "
}
if (timer_info.start_seconds) {
message_div.innerHTML += timer_info.start_seconds + " sec "
}
}
timer_div.appendChild(message_div)
let time_div = document.createElement("div")
time_div.classList.add("time")
let hours = timer_info.start_hours ?? 0
let minutes = timer_info.start_minutes ?? 0
let seconds = timer_info.start_seconds ?? 0
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0);
timer_div.appendChild(time_div)
timers_elem.appendChild(timer_div)
setTimeout(update_timer, 1000, timer_info)
}
else if (wyo_event.type == "timer-cancelled") {
let timer_info = wyo_event.data
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.remove()
}
}
else if (wyo_event.type == "timer-updated") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].is_active = timer_info.is_active
timers[i].total_seconds = timer_info.total_seconds
break
}
}
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
if (timer_info.is_active) {
timer_div.classList.remove("paused")
} else {
timer_div.classList.add("paused")
}
}
}
else if (wyo_event.type == "timer-finished") {
let timer_info = wyo_event.data
for (i = 0; i < timers.length; i++) {
if (timers[i].id == timer_info.id) {
timers[i].total_seconds = 0
finished_timers += 1
if (finished_timers == 1) {
q("#audio-finished").play()
}
break
}
}
timers = timers.filter(t => t.id != timer_info.id)
let timer_div = q("#timer-" + timer_info.id)
if (timer_div) {
timer_div.classList.add("finished")
timer_div.classList.add("blink")
timer_div.onclick = function() {
timer_div.remove()
finished_timers = Math.max(0, finished_timers - 1)
if (finished_timers == 0) {
let audio = q("#audio-finished")
audio.pause()
audio.currentTime = 0
}
};
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (time_div) {
time_div.style.opacity = 0
}
}
};
socket.onclose = function(event) {
console.log("Disconnected")
};
socket.onerror = function(error) {
};
function update_timer(timer_info) {
if (timer_info.total_seconds < 0) {
return
}
let time_div = q("#timer-" + timer_info.id + " .time")
if (!time_div) {
return
}
if (!timer_info.is_active) {
// Paused
setTimeout(update_timer, 1000, timer_info)
return
}
if (timer_info.total_seconds > 0) {
timer_info.total_seconds -= 1
}
let total_minutes = Math.floor(timer_info.total_seconds / 60)
let hours = Math.floor(total_minutes / 60)
let minutes = total_minutes % 60
let seconds = timer_info.total_seconds % 60
time_div.innerHTML =
hours.toString().padStart(2, 0) + ":" +
minutes.toString().padStart(2, 0) + ":" +
seconds.toString().padStart(2, 0)
setTimeout(update_timer, 1000, timer_info)
};
</script>
</body>
</html>
+3 -7
View File
@@ -123,7 +123,7 @@ def apply_settings(settings: Settings) -> None:
): ):
result = run_with_gauge( result = run_with_gauge(
"Installing vad...", "Installing vad...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_vad.txt"))], [pip_install("-e", f"{PROGRAM_DIR}[silerovad]")],
) )
if not result: if not result:
error("installing vad") error("installing vad")
@@ -135,11 +135,7 @@ def apply_settings(settings: Settings) -> None:
): ):
result = run_with_gauge( result = run_with_gauge(
"Installing audio enhancements...", "Installing audio enhancements...",
[ [pip_install("-e", f"{PROGRAM_DIR}[webrtc]")],
pip_install(
"-r", str(PROGRAM_DIR / "requirements_audio_enhancement.txt")
)
],
) )
if not result: if not result:
error("installing audio enhancements") error("installing audio enhancements")
@@ -154,7 +150,7 @@ def apply_settings(settings: Settings) -> None:
) and (not can_import("gpiozero", "spidev")): ) and (not can_import("gpiozero", "spidev")):
result = run_with_gauge( result = run_with_gauge(
"Installing event requirements...", "Installing event requirements...",
[pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))], [pip_install("-e", f"{PROGRAM_DIR}[respeaker]")],
) )
if not result: if not result:
error("installing event requirements") error("installing event requirements")
+61
View File
@@ -0,0 +1,61 @@
[build-system]
requires = ["setuptools>=62.3"]
build-backend = "setuptools.build_meta"
[project]
name = "wyoming_satellite"
version = "1.3.1"
license = {text = "MIT"}
description = "Wyoming server for remote voice satellite"
readme = "README.md"
authors = [
{name = "Michael Hansen", email = "mike@rhasspy.org"}
]
keywords = ["rhasspy", "wyoming", "satellite"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9.0"
dependencies = [
"wyoming==1.5.4",
"zeroconf==0.88.0",
"pyring-buffer>=1,<2",
]
[project.optional-dependencies]
all = ["pysilero-vad==1.0.0", "webrtc-noise-gain==1.2.3"]
silerovad = ["pysilero-vad==1.0.0"]
webrtc = ["webrtc-noise-gain==1.2.3"]
respeaker = ["gpiozero", "spidev"]
dev = [
"black==22.12.0",
"flake8==6.0.0",
"isort==5.11.3",
"mypy==0.991",
"pylint==2.15.9",
"pytest==7.4.3",
"pytest-asyncio==0.23.3",
"build==1.2.2"
]
[project.scripts]
wyoming-satellite = "wyoming_satellite:__main__.run"
[project.urls]
"Source Code" = "http://github.com/rhasspy/wyoming-satellite"
[tool.setuptools]
platforms = ["any"]
zip-safe = true
include-package-data = true
[tool.setuptools.packages.find]
include = ["wyoming_satellite"]
exclude = ["tests", "tests.*"]
-3
View File
@@ -1,3 +0,0 @@
wyoming==1.5.0
zeroconf==0.88.0
pyring-buffer==1.0.0
-1
View File
@@ -1 +0,0 @@
webrtc-noise-gain==1.2.3
-7
View File
@@ -1,7 +0,0 @@
black==22.12.0
flake8==6.0.0
isort==5.11.3
mypy==0.991
pylint==2.15.9
pytest==7.4.3
pytest-asyncio==0.23.3
-2
View File
@@ -1,2 +0,0 @@
gpiozero
spidev
-1
View File
@@ -1 +0,0 @@
pysilero-vad==1.0.0
+1 -1
View File
@@ -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' "$@"
+1 -1
View File
@@ -8,4 +8,4 @@ _PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv" _VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR) context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call([context.env_exe, _PROGRAM_DIR / "setup.py", "bdist_wheel"]) subprocess.check_call([context.env_exe, "-m", "build", "--wheel", "--sdist"])
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
import sys
import subprocess
import venv
from pathlib import Path
_DIR = Path(__file__).parent
_PROGRAM_DIR = _DIR.parent
_VENV_DIR = _PROGRAM_DIR / ".venv"
context = venv.EnvBuilder().ensure_directories(_VENV_DIR)
subprocess.check_call(
[context.env_exe, str(_PROGRAM_DIR / "examples" / "usbmic_service.py")]
+ sys.argv[1:]
)
+3 -4
View File
@@ -20,7 +20,6 @@ builder.create(_VENV_DIR)
# Upgrade dependencies # Upgrade dependencies
pip = [context.env_exe, "-m", "pip"] pip = [context.env_exe, "-m", "pip"]
subprocess.check_call(pip + ["install", "--upgrade", "pip"]) subprocess.check_call(pip + ["install", "--upgrade", "pip"])
subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"])
# Install requirements # Install requirements
subprocess.check_call( subprocess.check_call(
@@ -31,13 +30,13 @@ subprocess.check_call(
"https://www.piwheels.org/simple", "https://www.piwheels.org/simple",
"-f", "-f",
"https://synesthesiam.github.io/prebuilt-apps/", "https://synesthesiam.github.io/prebuilt-apps/",
"-r", "-e",
str(_PROGRAM_DIR / "requirements.txt"), str(_PROGRAM_DIR),
] ]
) )
if args.dev: if args.dev:
# Install dev requirements # Install dev requirements
subprocess.check_call( subprocess.check_call(
pip + ["install", "-r", str(_PROGRAM_DIR / "requirements_dev.txt")] pip + ["install", "-e", f"{_PROGRAM_DIR}[dev]"]
) )
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env python3
from pathlib import Path
from typing import List
import setuptools
from setuptools import setup
this_dir = Path(__file__).parent
def get_requirements(req_path: Path) -> List[str]:
if not req_path.is_file():
return []
requirements: List[str] = []
with open(req_path, "r", encoding="utf-8") as req_file:
for line in req_file:
line = line.strip()
if not line:
continue
requirements.append(line)
return requirements
install_requires = get_requirements(this_dir / "requirements.txt")
extras_require = {
"silerovad": get_requirements(this_dir / "requirements_vad.txt"),
"webrtc": get_requirements(this_dir / "requirements_audio_enhancement.txt"),
}
# -----------------------------------------------------------------------------
setup(
name="wyoming_satellite",
version="1.1.0",
description="Wyoming server for remote voice satellite",
url="http://github.com/rhasspy/wyoming-satellite",
author="Michael Hansen",
author_email="mike@rhasspy.org",
packages=setuptools.find_packages(),
install_requires=install_requires,
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
keywords="rhasspy wyoming satellite",
extras_require=extras_require,
entry_points={
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
},
)
Binary file not shown.
+59 -1
View File
@@ -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
+222
View File
@@ -0,0 +1,222 @@
import asyncio
import logging
from typing import Final, Optional
from unittest.mock import patch
import pytest
from wyoming.asr import Transcript
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncClient
from wyoming.event import Event, async_read_event
from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
from wyoming.tts import Synthesize
from wyoming.wake import Detection
from wyoming_satellite import (
EventSettings,
MicSettings,
SatelliteSettings,
SndSettings,
WakeSettings,
WakeStreamingSatellite,
)
from .shared import (
AUDIO_CHUNK,
AUDIO_START,
AUDIO_STOP,
FakeStreamReaderWriter,
MicClient,
)
_LOGGER = logging.getLogger()
TIMEOUT: Final = 1
class WakeClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self._event_ready = asyncio.Event()
self._event: Optional[Event] = None
self._detected: bool = False
async def read_event(self) -> Optional[Event]:
await self._event_ready.wait()
self._event_ready.clear()
return self._event
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
if not self._detected:
self._detected = True
self._event = Detection().event()
self._event_ready.set()
class SndClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
pass
async def write_event(self, event: Event) -> None:
if AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
class EventClient(AsyncClient):
def __init__(self) -> None:
super().__init__()
self.detection = asyncio.Event()
self.streaming_started = asyncio.Event()
self.streaming_stopped = asyncio.Event()
self.transcript = asyncio.Event()
self.synthesize = asyncio.Event()
self.audio_start = asyncio.Event()
self.audio_chunk = asyncio.Event()
self.audio_stop = asyncio.Event()
async def read_event(self) -> Optional[Event]:
# Input only
return None
async def write_event(self, event: Event) -> None:
if Detection.is_type(event.type):
self.detection.set()
elif StreamingStarted.is_type(event.type):
self.streaming_started.set()
elif StreamingStopped.is_type(event.type):
self.streaming_stopped.set()
elif Transcript.is_type(event.type):
self.transcript.set()
elif Synthesize.is_type(event.type):
self.synthesize.set()
elif AudioChunk.is_type(event.type):
self.audio_chunk.set()
elif AudioStart.is_type(event.type):
self.audio_start.set()
elif AudioStop.is_type(event.type):
self.audio_stop.set()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_satellite() -> None:
mic_client = MicClient()
snd_client = SndClient()
wake_client = WakeClient()
event_client = EventClient()
with patch(
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
return_value=mic_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
return_value=snd_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
return_value=wake_client,
), patch(
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
return_value=event_client,
):
satellite = WakeStreamingSatellite(
SatelliteSettings(
mic=MicSettings(uri="test"),
snd=SndSettings(uri="test"),
wake=WakeSettings(uri="test"),
event=EventSettings(uri="test"),
)
)
async def event_from_satellite() -> Optional[Event]:
return await async_read_event(server_io)
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
# Fake server connection
server_io = FakeStreamReaderWriter()
await satellite.set_server("test", server_io) # type: ignore
# Start satellite
await satellite.event_from_server(RunSatellite().event())
# Trigger detection
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert Detection.is_type(event.type), event
# Pipeline should start
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert RunPipeline.is_type(event.type), event
run_pipeline = RunPipeline.from_event(event)
assert run_pipeline.start_stage == PipelineStage.ASR
assert run_pipeline.end_stage == PipelineStage.TTS
# Event service should have received detection
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
# Server should be receiving audio now
assert satellite.is_streaming, "Not streaming"
for _ in range(5):
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming start
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
# Send transcript
await satellite.event_from_server(Transcript(text="test").event())
# Event service should have received transcript
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
# Wait for streaming to stop
while satellite.is_streaming:
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
assert event is not None
assert AudioChunk.is_type(event.type)
# Event service should have received streaming stop
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
# Fake a TTS response
await satellite.event_from_server(Synthesize(text="test").event())
# Event service should have received synthesize
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
# Audio start, chunk, stop
await satellite.event_from_server(AUDIO_START.event())
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
# Event service does not get audio chunks, just start/stop
await satellite.event_from_server(AUDIO_CHUNK.event())
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
await satellite.event_from_server(AUDIO_STOP.event())
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
# Stop satellite
await satellite.stop()
await satellite_task
+4 -13
View File
@@ -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())
+14
View File
@@ -0,0 +1,14 @@
[tox]
env_list =
py{39,310,311,312,313}
minversion = 4.12.1
[testenv]
description = run the tests with pytest
package = wheel
wheel_build_env = .pkg
deps =
pytest>=7,<8
pytest-asyncio<1
commands =
pytest {tty:--color=yes} {posargs}
+9 -1
View File
@@ -1,4 +1,8 @@
"""Voice satellite using the Wyoming protocol.""" """Voice satellite using the Wyoming protocol."""
import importlib.metadata
from pathlib import Path
from .satellite import ( from .satellite import (
AlwaysStreamingSatellite, AlwaysStreamingSatellite,
SatelliteBase, SatelliteBase,
@@ -14,7 +18,11 @@ from .settings import (
WakeSettings, WakeSettings,
) )
_DIR = Path(__file__).parent
__version__ = importlib.metadata.version("wyoming-satellite")
__all__ = [ __all__ = [
"__version__",
"AlwaysStreamingSatellite", "AlwaysStreamingSatellite",
"EventSettings", "EventSettings",
"MicSettings", "MicSettings",
@@ -23,6 +31,6 @@ __all__ = [
"SndSettings", "SndSettings",
"VadSettings", "VadSettings",
"VadStreamingSatellite", "VadStreamingSatellite",
"WakeSettings",
"WakeStreamingSatellite", "WakeStreamingSatellite",
"WakeSettings",
] ]
+94 -7
View File
@@ -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
],
refractory_seconds=(
args.wake_refractory_seconds
if args.wake_refractory_seconds > 0 if args.wake_refractory_seconds > 0
else None, 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,
) )
+3 -2
View File
@@ -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:
+240 -29
View File
@@ -1,36 +1,48 @@
"""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 +50,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 +62,12 @@ class State(Enum):
STOPPED = auto() STOPPED = auto()
@dataclass
class SoundEvent:
event: Event
is_tts: bool
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -65,7 +84,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 +92,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 +168,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 +188,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 +220,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 +243,7 @@ class SatelliteBase:
self._writer = None self._writer = None
await self._disconnect_from_services() await self._disconnect_from_services()
self._disable_ping()
self.state = State.STOPPED self.state = State.STOPPED
async def stopped(self) -> None: async def stopped(self) -> None:
@@ -217,19 +251,27 @@ class SatelliteBase:
async def event_from_server(self, event: Event) -> None: async def event_from_server(self, event: Event) -> None:
"""Called when an event is received from the server.""" """Called when an event is received from the server."""
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())
if not self._ping_server_enabled:
# Enable pinging # Enable pinging
self._ping_server_enabled = True 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 +304,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 +340,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 +488,38 @@ class SatelliteBase:
continue continue
# Audio processing # Audio processing
if self.settings.mic.needs_processing and AudioChunk.is_type( if (
event.type self.settings.mic.needs_processing
or (self.settings.mic.channel_index is not None)
and AudioChunk.is_type(event.type)
): ):
chunk = AudioChunk.from_event(event) chunk = AudioChunk.from_event(event)
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) 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 +553,10 @@ class SatelliteBase:
# Sound # Sound
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def event_to_snd(self, event: Event) -> None: async def event_to_snd(self, event: Event, is_tts: bool = True) -> None:
"""Send an event to the sound service.""" """Send an event to the sound service."""
if self._snd_queue is not None: if self._snd_queue is not None:
self._snd_queue.put_nowait(event) self._snd_queue.put_nowait(SoundEvent(event, is_tts))
def _make_snd_client(self) -> Optional[AsyncClient]: def _make_snd_client(self) -> Optional[AsyncClient]:
"""Create client for snd service.""" """Create client for snd service."""
@@ -505,7 +591,8 @@ class SatelliteBase:
if self._snd_queue is None: if self._snd_queue is None:
self._snd_queue = asyncio.Queue() self._snd_queue = asyncio.Queue()
event = await self._snd_queue.get() snd_event = await self._snd_queue.get()
event = snd_event.event
if snd_client is None: if snd_client is None:
snd_client = self._make_snd_client() snd_client = self._make_snd_client()
@@ -532,6 +619,8 @@ class SatelliteBase:
event.type event.type
): ):
await _disconnect() await _disconnect()
if snd_event.is_tts:
await self.trigger_played()
snd_client = None # reconnect on next event snd_client = None # reconnect on next event
except asyncio.CancelledError: except asyncio.CancelledError:
break break
@@ -575,9 +664,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 +803,18 @@ class SatelliteBase:
async def _send_wake_detect(self) -> None: async def _send_wake_detect(self) -> None:
"""Inform wake word service of which wake words to detect.""" """Inform wake word service of which wake words to detect."""
await self.event_to_wake(Detect(names=self.settings.wake.names).event()) wake_names: Optional[List[str]] = None
if self.settings.wake.names:
wake_names = [w.name for w in self.settings.wake.names]
await self.event_to_wake(Detect(names=wake_names).event())
await self.event_to_wake(
AudioStart(
rate=self.settings.mic.rate,
width=self.settings.mic.width,
channels=self.settings.mic.channels,
).event()
)
await self.trigger_detect() await self.trigger_detect()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -756,6 +855,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 +889,28 @@ class SatelliteBase:
"""Called when an error occurs on the server.""" """Called when an error occurs on the server."""
await run_event_command(self.settings.event.error, error.text) await run_event_command(self.settings.event.error, error.text)
async def trigger_timer_started(self, timer_started: TimerStarted) -> None:
"""Called when timer-started event is received."""
await run_event_command(self.settings.timer.started, timer_started)
async def trigger_timer_updated(self, timer_updated: TimerUpdated) -> None:
"""Called when timer-updated event is received."""
await run_event_command(self.settings.timer.updated, timer_updated)
async def trigger_timer_cancelled(self, timer_cancelled: TimerCancelled) -> None:
"""Called when timer-cancelled event is received."""
await run_event_command(self.settings.timer.cancelled, timer_cancelled.id)
async def trigger_timer_finished(self, timer_finished: TimerFinished) -> None:
"""Called when timer-finished event is received."""
await run_event_command(self.settings.timer.finished, timer_finished.id)
for _ in range(self.settings.timer.finished_wav_plays):
await self._play_wav(
self.settings.timer.finished_wav,
mute_microphone=self.settings.mic.mute_during_awake_wav,
)
await asyncio.sleep(self.settings.timer.finished_wav_delay)
async def forward_event(self, event: Event) -> None: async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service.""" """Forward an event to the event service."""
if self._event_queue is not None: if self._event_queue is not None:
@@ -833,6 +959,13 @@ class SatelliteBase:
await _disconnect() await _disconnect()
# -------------------------------------------------------------------------
# Info
# -------------------------------------------------------------------------
async def update_info(self, info: Info) -> None:
pass
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -858,6 +991,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 +1061,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 +1091,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 +1216,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 +1250,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
if is_pause_satellite:
self._is_paused = True
_LOGGER.debug("Satellite is paused")
else:
# Go back to wake word detection
await self.trigger_streaming_stop() 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() await self._send_wake_detect()
_LOGGER.info("Waiting for wake word") _LOGGER.info("Waiting for wake word")
# Start debug recording (wake) # Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns() self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None: if self.wake_audio_writer is not None:
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp) 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 +1287,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 +1314,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 +1342,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 +1357,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")
+41 -1
View File
@@ -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
+2
View File
@@ -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",
+34 -2
View File
@@ -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