31 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
Luis Andrade 886019e04f Merge branch 'rhasspy:master' into bugfix-wav-volume 2024-02-19 14:45:56 -05: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
34 changed files with 755 additions and 131 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
* *
!requirements.txt !pyproject.toml
!MANIFEST.in
!setup.py
!script/setup !script/setup
!script/run !script/run
!wyoming_satellite/*.py !wyoming_satellite/*.py
+1 -1
View File
@@ -9,7 +9,7 @@ tmp/
build build
htmlcov htmlcov
/.venv/ .venv/
.mypy_cache/ .mypy_cache/
__pycache__/ __pycache__/
+10
View File
@@ -1,5 +1,15 @@
# 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 ## 1.2.0
- Add `--tts-played-command` - Add `--tts-played-command`
+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
-2
View File
@@ -1,2 +0,0 @@
include requirements.txt
include VERSION
+7
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:
@@ -179,5 +182,9 @@ Satellites can respond to events from the server by running commands:
* `--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 -4
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:
@@ -305,6 +303,13 @@ python3 -m venv --system-site-packages .venv
.venv/bin/pip3 install 'wyoming==1.5.2' .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.3
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
@@ -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]"]
) )
-64
View File
@@ -1,64 +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
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]:
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(),
package_data={
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
},
install_requires=install_requires,
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",
],
keywords="rhasspy wyoming satellite",
extras_require=extras_require,
entry_points={
"console_scripts": ["wyoming-satellite = wyoming_satellite:__main__.run"]
},
)
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
[tox] [tox]
env_list = env_list =
py{39,310,311} py{39,310,311,312,313}
minversion = 4.12.1 minversion = 4.12.1
[testenv] [testenv]
-1
View File
@@ -1 +0,0 @@
1.2.0
+3 -1
View File
@@ -1,4 +1,6 @@
"""Voice satellite using the Wyoming protocol.""" """Voice satellite using the Wyoming protocol."""
import importlib.metadata
from pathlib import Path from pathlib import Path
from .satellite import ( from .satellite import (
@@ -17,7 +19,7 @@ from .settings import (
) )
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip() __version__ = importlib.metadata.version("wyoming-satellite")
__all__ = [ __all__ = [
"__version__", "__version__",
+49 -4
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
@@ -22,6 +23,7 @@ from .settings import (
MicSettings, MicSettings,
SatelliteSettings, SatelliteSettings,
SndSettings, SndSettings,
TimerSettings,
VadSettings, VadSettings,
WakeSettings, WakeSettings,
WakeWordAndPipeline, WakeWordAndPipeline,
@@ -225,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(
@@ -233,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://")
@@ -278,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()):
@@ -296,6 +326,10 @@ 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")
@@ -347,9 +381,11 @@ async def main() -> None:
names=[ names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
], ],
refractory_seconds=args.wake_refractory_seconds 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,
@@ -379,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,
) )
+69 -10
View File
@@ -1,10 +1,12 @@
"""Satellite code.""" """Satellite code."""
import array 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, List, Optional, Set, Union from typing import Callable, Dict, Final, List, Optional, Set, Union
@@ -28,6 +30,7 @@ from wyoming.satellite import (
StreamingStopped, StreamingStopped,
) )
from wyoming.snd import Played, 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
@@ -59,6 +62,12 @@ class State(Enum):
STOPPED = auto() STOPPED = auto()
@dataclass
class SoundEvent:
event: Event
is_tts: bool
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -75,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
@@ -242,6 +251,8 @@ 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)
@@ -251,12 +262,16 @@ class SatelliteBase:
# Enable pinging # Enable pinging
self._enable_ping() self._enable_ping()
_LOGGER.debug("Ping enabled") _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)
@@ -289,9 +304,21 @@ 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, pipeline_name: Optional[str] = None) -> None: async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
@@ -488,9 +515,11 @@ class SatelliteBase:
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) if (self.settings.mic.channel_index is None)
else 1, else 1
),
audio=audio_bytes, audio=audio_bytes,
).event() ).event()
else: else:
@@ -524,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."""
@@ -562,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()
@@ -589,6 +619,7 @@ class SatelliteBase:
event.type event.type
): ):
await _disconnect() await _disconnect()
if snd_event.is_tts:
await self.trigger_played() await self.trigger_played()
snd_client = None # reconnect on next event snd_client = None # reconnect on next event
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -633,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
@@ -778,6 +808,13 @@ class SatelliteBase:
wake_names = [w.name for w in 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(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()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -852,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:
+28
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
@@ -172,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."""
@@ -181,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 -2
View File
@@ -1,24 +1,32 @@
"""Miscellaneous utilities.""" """Miscellaneous utilities."""
import argparse import argparse
import asyncio import asyncio
import json
import logging import logging
import re import re
import shlex import shlex
import unicodedata import unicodedata
import uuid import uuid
from functools import lru_cache from functools import lru_cache
from typing import List, Optional 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(