Author SHA1 Message Date
Michael Hansen 37c11e58c4 Allow remotely triggered pipelines for wake word satellites 2024-08-09 14:05:20 -05: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
33 changed files with 1075 additions and 159 deletions
+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`
+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.
+11 -1
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:
@@ -305,6 +305,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 +392,7 @@ Try a voice command and see if the LEDs change. Use `journalctl` to check the lo
journalctl -u 2mic_leds.service -f journalctl -u 2mic_leds.service -f
``` ```
If you encounter any issues, you can add the `--debug` argument to the command line to increase the log level.
To control the brightness of the LEDS, use the `--led-brightness ` argument, which accepts integer numbers from 1 to 31.
Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service. Make sure to run `sudo systemctl daemon-reload` every time you make changes to the service.
+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}"
} }
+11 -4
View File
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted from wyoming.vad import VoiceStarted
from wyoming.wake import Detection from wyoming.wake import Detection
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
NUM_LEDS = 3 NUM_LEDS = 3
LEDS_GPIO = 12 LEDS_GPIO = 12
@@ -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
+1 -1
View File
@@ -23,7 +23,7 @@ from wyoming.server import AsyncEventHandler, AsyncServer
from wyoming.vad import VoiceStarted from wyoming.vad import VoiceStarted
from wyoming.wake import Detection from wyoming.wake import Detection
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
NUM_LEDS = 12 NUM_LEDS = 12
LEDS_GPIO = 5 LEDS_GPIO = 5
+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(__name__)
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Turn on power to LEDs
pixel_ring.set_color_palette(0x0080FF, 0x007A37)
pixel_ring.think()
await asyncio.sleep(3)
pixel_ring.off()
# Start server
server = AsyncServer.from_uri(args.uri)
try:
await server.run(partial(LEDsEventHandler, args))
except KeyboardInterrupt:
pass
finally:
pixel_ring.off()
class LEDsEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.client_id = str(time.monotonic_ns())
_LOGGER.debug("Client connected: %s", self.client_id)
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
if Detection.is_type(event.type):
_LOGGER.debug("Detection")
pixel_ring.wakeup()
elif VoiceStarted.is_type(event.type):
_LOGGER.debug("VoiceStarted")
pixel_ring.speak()
elif VoiceStopped.is_type(event.type):
_LOGGER.debug("VoiceStopped")
pixel_ring.spin()
elif StreamingStopped.is_type(event.type):
_LOGGER.debug("StreamingStopped")
pixel_ring.off()
elif SatelliteConnected.is_type(event.type):
_LOGGER.debug("SatelliteConnected")
pixel_ring.think()
await asyncio.sleep(2)
pixel_ring.off()
elif Played.is_type(event.type):
_LOGGER.debug("Played")
pixel_ring.off()
elif SatelliteDisconnected.is_type(event.type):
_LOGGER.debug("SatelliteDisconnected")
pixel_ring.mono(0xFF0000)
return True
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
@@ -0,0 +1 @@
websockets==12.0
+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(__name__)
async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True, help="unix:// or tcp://")
parser.add_argument("--websocket-host", default="localhost")
parser.add_argument("--websocket-port", type=int, default=8675)
#
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
_LOGGER.debug(args)
_LOGGER.info("Ready")
# Start server
server = AsyncServer.from_uri(args.uri)
queue: "asyncio.Queue[Optional[Event]]" = asyncio.Queue()
try:
async with websockets.serve(
partial(websocket_connected, queue),
args.websocket_host,
args.websocket_port,
):
await server.run(partial(WebsocketEventHandler, args, queue))
finally:
queue.put_nowait(None)
# -----------------------------------------------------------------------------
async def websocket_connected(queue: "asyncio.Queue[Optional[Event]]", websocket):
try:
while True:
event = await queue.get()
if event is None:
# Stop signal
break
await websocket.send(
json.dumps(
{"type": event.type, "data": event.data or {}}, ensure_ascii=False
)
)
except websockets.ConnectionClosed:
pass
except Exception:
_LOGGER.exception("Error in websocket handler")
# -----------------------------------------------------------------------------
class WebsocketEventHandler(AsyncEventHandler):
"""Event handler for clients."""
def __init__(
self,
cli_args: argparse.Namespace,
queue: "asyncio.Queue[Optional[Event]]",
*args,
**kwargs,
) -> None:
super().__init__(*args, **kwargs)
self.cli_args = cli_args
self.queue = queue
async def handle_event(self, event: Event) -> bool:
_LOGGER.debug(event)
self.queue.put_nowait(event)
return True
# -----------------------------------------------------------------------------
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
Binary file not shown.
+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>
+1 -1
View File
@@ -17,7 +17,7 @@ from .speakers import configure_speakers
from .wake_word import configure_wake_word from .wake_word import configure_wake_word
from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge from .whiptail import ItemType, error, menu, msgbox, passwordbox, run_with_gauge
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
def main() -> None: def main() -> None:
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import List, Optional
from .dataclasses_json import DataClassJsonMixin from .dataclasses_json import DataClassJsonMixin
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
PROGRAM_DIR = _DIR.parent PROGRAM_DIR = _DIR.parent
LOCAL_DIR = PROGRAM_DIR / "local" LOCAL_DIR = PROGRAM_DIR / "local"
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Dict, List, Optional
from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings from .const import RECORD_RMS_MIN, RECORD_SECONDS, Settings
from .whiptail import gauge, inputbox, menu, msgbox, radiolist from .whiptail import gauge, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
def configure_microphone(settings: Settings) -> None: def configure_microphone(settings: Settings) -> None:
+1 -1
View File
@@ -5,7 +5,7 @@ import subprocess
from .const import PROGRAM_DIR from .const import PROGRAM_DIR
from .whiptail import run_with_gauge from .whiptail import run_with_gauge
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
def packages_installed(*packages) -> bool: def packages_installed(*packages) -> bool:
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from .const import PROGRAM_DIR, Settings from .const import PROGRAM_DIR, Settings
from .whiptail import checklist, inputbox, menu, msgbox, radiolist from .whiptail import checklist, inputbox, menu, msgbox, radiolist
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
def configure_speakers(settings: Settings) -> None: def configure_speakers(settings: Settings) -> None:
+1 -1
View File
@@ -11,7 +11,7 @@ from .const import HEIGHT, LIST_HEIGHT, TITLE, WIDTH
ItemType = Union[str, Tuple[Any, str]] ItemType = Union[str, Tuple[Any, str]]
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
def whiptail(*args) -> Optional[str]: def whiptail(*args) -> Optional[str]:
+1 -1
View File
@@ -1,3 +1,3 @@
wyoming==1.5.3 wyoming==1.5.4
zeroconf==0.88.0 zeroconf==0.88.0
pyring-buffer==1.0.0 pyring-buffer==1.0.0
+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:]
)
Binary file not shown.
+1 -1
View File
@@ -30,7 +30,7 @@ from .shared import (
MicClient, MicClient,
) )
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
TIMEOUT: Final = 1 TIMEOUT: Final = 1
+1 -1
View File
@@ -22,7 +22,7 @@ from wyoming_satellite import (
from .shared import MicClient from .shared import MicClient
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
class WakeClient(AsyncClient): class WakeClient(AsyncClient):
+1 -1
View File
@@ -1 +1 @@
1.2.0 1.4.0
+100 -17
View File
@@ -1,12 +1,15 @@
"""Main entry point for Wyoming satellite.""" """Main entry point for Wyoming satellite."""
import argparse import argparse
import asyncio import asyncio
import logging import logging
import shlex
import sys import sys
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from wyoming.info import Attribution, Info, Satellite from wyoming.audio import AudioFormat
from wyoming.info import Attribution, Info, MicProgram, Satellite, SndProgram
from wyoming.server import AsyncServer, AsyncTcpServer from wyoming.server import AsyncServer, AsyncTcpServer
from . import __version__ from . import __version__
@@ -22,9 +25,9 @@ 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,
@@ -34,7 +37,7 @@ from .utils import (
split_command, split_command,
) )
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
_DIR = Path(__file__).parent _DIR = Path(__file__).parent
@@ -120,9 +123,7 @@ async def main() -> None:
"--wake-word-name", "--wake-word-name",
action="append", action="append",
default=[], default=[],
nargs="+", help="Name of wake word to listen for (requires --wake-uri)",
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(
@@ -152,9 +153,14 @@ async def main() -> None:
# Voice activity detector # Voice activity detector
parser.add_argument( parser.add_argument(
"--vad", action="store_true", help="Wait for speech before streaming audio" "--vad", action="store_true", help="Enable voice activity detector"
)
parser.add_argument(
"--vad-threshold",
type=float,
default=0.5,
help="Threshold for speech (0-1, default: 0.5)",
) )
parser.add_argument("--vad-threshold", type=float, default=0.5)
parser.add_argument("--vad-trigger-level", type=int, default=1) parser.add_argument("--vad-trigger-level", type=int, default=1)
parser.add_argument("--vad-buffer-seconds", type=float, default=2) parser.add_argument("--vad-buffer-seconds", type=float, default=2)
parser.add_argument( parser.add_argument(
@@ -163,6 +169,7 @@ async def main() -> None:
default=5.0, default=5.0,
help="Seconds before going back to waiting for speech when wake word isn't detected", help="Seconds before going back to waiting for speech when wake word isn't detected",
) )
parser.add_argument("--vad-finished-speaking-seconds", type=float, default=0.8)
# External event handlers # External event handlers
parser.add_argument( parser.add_argument(
@@ -225,6 +232,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 +257,17 @@ async def main() -> None:
parser.add_argument( parser.add_argument(
"--done-wav", help="WAV file to play when voice command is done" "--done-wav", help="WAV file to play when voice command is done"
) )
parser.add_argument(
"--timer-finished-wav", help="WAV file to play when a timer finishes"
)
parser.add_argument(
"--timer-finished-wav-repeat",
nargs=2,
metavar=("repeat", "delay"),
type=float,
default=(1, 0),
help="Number of times to play timer finished WAV and delay between repeats in seconds",
)
# Satellite details # Satellite details
parser.add_argument("--uri", required=True, help="unix:// or tcp://") parser.add_argument("--uri", required=True, help="unix:// or tcp://")
@@ -278,14 +313,14 @@ async def main() -> None:
try: try:
import webrtc_noise_gain # noqa: F401 import webrtc_noise_gain # noqa: F401
except ImportError: except ImportError:
_LOGGER.fatal("Install extras for webrtc") _LOGGER.exception("Extras for webrtc are not installed")
sys.exit(1) sys.exit(1)
if needs_silero(args): if needs_silero(args):
try: try:
import pysilero_vad # noqa: F401 import pysilero_vad # noqa: F401
except ImportError: except ImportError:
_LOGGER.fatal("Install extras for silerovad") _LOGGER.exception("Extras for silerovad are not installed")
sys.exit(1) sys.exit(1)
if args.awake_wav and (not Path(args.awake_wav).is_file()): if args.awake_wav and (not Path(args.awake_wav).is_file()):
@@ -296,8 +331,9 @@ async def main() -> None:
_LOGGER.fatal("%s does not exist", args.done_wav) _LOGGER.fatal("%s does not exist", args.done_wav)
sys.exit(1) sys.exit(1)
if args.vad and (args.wake_uri or args.wake_command): if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
_LOGGER.warning("VAD is not used with local wake word detection") _LOGGER.fatal("%s does not exist", args.timer_finished_wav)
sys.exit(1)
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
@@ -316,9 +352,46 @@ async def main() -> None:
attribution=Attribution(name="", url=""), attribution=Attribution(name="", url=""),
installed=True, installed=True,
version=__version__, version=__version__,
has_vad=args.vad,
supports_trigger=True,
active_wake_words=args.wake_word_name,
) )
) )
if args.mic_command:
mic_program = shlex.split(args.mic_command)[0]
wyoming_info.mic = [
MicProgram(
name=mic_program,
description=args.mic_command,
attribution=Attribution(name="", url=""),
installed=True,
version=None,
mic_format=AudioFormat(
rate=args.mic_command_rate,
width=args.mic_command_width,
channels=args.mic_command_channels,
),
)
]
if args.snd_command:
snd_program = shlex.split(args.snd_command)[0]
wyoming_info.snd = [
SndProgram(
name=snd_program,
description=args.snd_command,
attribution=Attribution(name="", url=""),
installed=True,
version=None,
snd_format=AudioFormat(
rate=args.snd_command_rate,
width=args.snd_command_width,
channels=args.snd_command_channels,
),
)
]
settings = SatelliteSettings( settings = SatelliteSettings(
mic=MicSettings( mic=MicSettings(
uri=args.mic_uri, uri=args.mic_uri,
@@ -340,16 +413,17 @@ async def main() -> None:
trigger_level=args.vad_trigger_level, trigger_level=args.vad_trigger_level,
buffer_seconds=args.vad_buffer_seconds, buffer_seconds=args.vad_buffer_seconds,
wake_word_timeout=args.vad_wake_word_timeout, wake_word_timeout=args.vad_wake_word_timeout,
finished_speaking_seconds=args.vad_finished_speaking_seconds,
), ),
wake=WakeSettings( wake=WakeSettings(
uri=args.wake_uri, uri=args.wake_uri,
command=split_command(args.wake_command), command=split_command(args.wake_command),
names=[ names=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 +453,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,
) )
+1 -1
View File
@@ -9,7 +9,7 @@ from wyoming.server import AsyncEventHandler
from .satellite import SatelliteBase from .satellite import SatelliteBase
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
class SatelliteEventHandler(AsyncEventHandler): class SatelliteEventHandler(AsyncEventHandler):
+1 -1
View File
@@ -8,7 +8,7 @@ from functools import partial
from wyoming.event import Event from wyoming.event import Event
from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.server import AsyncEventHandler, AsyncServer
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
async def main() -> None: async def main() -> None:
+255 -60
View File
@@ -1,17 +1,20 @@
"""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 queue import Queue
from typing import Callable, Dict, Final, List, 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, AudioFormat, AudioStart, AudioStop from wyoming.audio import AudioChunk, 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
@@ -28,6 +31,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
@@ -40,10 +44,10 @@ from .utils import (
run_event_command, run_event_command,
wav_to_events, wav_to_events,
) )
from .vad import SileroVad from .vad import SileroVad, VoiceCommandSegmenter
from .webrtc import WebRtcAudio from .webrtc import WebRtcAudio
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
_PONG_TIMEOUT: Final = 5 _PONG_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2 _PING_SEND_DELAY: Final = 2
@@ -59,6 +63,12 @@ class State(Enum):
STOPPED = auto() STOPPED = auto()
@dataclass
class SoundEvent:
event: Event
is_tts: bool
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -75,7 +85,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
@@ -101,6 +111,11 @@ class SatelliteBase:
settings.debug_recording_dir, "stt" settings.debug_recording_dir, "stt"
) )
# Names of wake words to listen for
self._wake_names: Optional[List[str]] = None
if self.settings.wake.names:
self._wake_names = self.settings.wake.names
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
"""True if not stopping/stopped.""" """True if not stopping/stopped."""
@@ -242,6 +257,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 +268,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)
@@ -265,13 +286,6 @@ class SatelliteBase:
# TTS stopped # TTS stopped
await self.event_to_snd(event) await self.event_to_snd(event)
await self.trigger_tts_stop() await self.trigger_tts_stop()
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
elif Detection.is_type(event.type):
# Wake word detected
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif VoiceStarted.is_type(event.type): elif VoiceStarted.is_type(event.type):
# STT start # STT start
await self.trigger_stt_start() await self.trigger_stt_start()
@@ -289,13 +303,33 @@ class SatelliteBase:
elif Error.is_type(event.type): elif Error.is_type(event.type):
_LOGGER.warning(event) _LOGGER.warning(event)
await self.trigger_error(Error.from_event(event)) await self.trigger_error(Error.from_event(event))
elif TimerStarted.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_started(TimerStarted.from_event(event))
elif TimerUpdated.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_updated(TimerUpdated.from_event(event))
elif TimerCancelled.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_cancelled(TimerCancelled.from_event(event))
elif TimerFinished.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_finished(TimerFinished.from_event(event))
# Forward everything except audio to event service # Forward everything except audio/ping/pong to event service
if not AudioChunk.is_type(event.type): if forward_event:
await self.forward_event(event) await self.forward_event(event)
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None: async def _send_run_pipeline(
self,
start_stage: Optional[PipelineStage] = None,
end_stage: Optional[PipelineStage] = None,
wake_word_name: Optional[str] = None,
) -> None:
"""Sends a RunPipeline event with the correct stages.""" """Sends a RunPipeline event with the correct stages."""
restart_on_end = False
if start_stage is None:
if self.settings.wake.enabled: if self.settings.wake.enabled:
# Local wake word detection # Local wake word detection
start_stage = PipelineStage.ASR start_stage = PipelineStage.ASR
@@ -305,6 +339,7 @@ class SatelliteBase:
start_stage = PipelineStage.WAKE start_stage = PipelineStage.WAKE
restart_on_end = not self.settings.vad.enabled restart_on_end = not self.settings.vad.enabled
if end_stage is None:
if self.settings.snd.enabled: if self.settings.snd.enabled:
# Play TTS response # Play TTS response
end_stage = PipelineStage.TTS end_stage = PipelineStage.TTS
@@ -315,13 +350,8 @@ class SatelliteBase:
run_pipeline = RunPipeline( run_pipeline = RunPipeline(
start_stage=start_stage, start_stage=start_stage,
end_stage=end_stage, end_stage=end_stage,
name=pipeline_name, wake_word_name=wake_word_name,
restart_on_end=restart_on_end, 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) _LOGGER.debug(run_pipeline)
await self.event_to_server(run_pipeline) await self.event_to_server(run_pipeline)
@@ -450,7 +480,10 @@ class SatelliteBase:
mic_client = self._make_mic_client() mic_client = self._make_mic_client()
assert mic_client is not None assert mic_client is not None
await mic_client.connect() await mic_client.connect()
_LOGGER.debug("Connected to mic service") _LOGGER.info(
"Connected to mic service: %s",
self.settings.mic.uri or self.settings.mic.command,
)
event = await mic_client.read_event() event = await mic_client.read_event()
if event is None: if event is None:
@@ -488,9 +521,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 +559,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,13 +597,17 @@ 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()
assert snd_client is not None assert snd_client is not None
await snd_client.connect() await snd_client.connect()
_LOGGER.debug("Connected to snd service") _LOGGER.info(
"Connected to snd service: %s",
self.settings.snd.uri or self.settings.snd.command,
)
# Audio processing # Audio processing
if self.settings.snd.needs_processing and AudioChunk.is_type( if self.settings.snd.needs_processing and AudioChunk.is_type(
@@ -589,7 +628,10 @@ class SatelliteBase:
event.type event.type
): ):
await _disconnect() await _disconnect()
if snd_event.is_tts:
await self.trigger_played() await self.trigger_played()
await self.event_to_server(Played().event())
snd_client = None # reconnect on next event snd_client = None # reconnect on next event
except asyncio.CancelledError: except asyncio.CancelledError:
break break
@@ -633,9 +675,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
@@ -709,7 +750,10 @@ class SatelliteBase:
wake_client = self._make_wake_client() wake_client = self._make_wake_client()
assert wake_client is not None assert wake_client is not None
await wake_client.connect() await wake_client.connect()
_LOGGER.debug("Connected to wake service") _LOGGER.info(
"Connected to wake service: %s",
self.settings.wake.uri or self.settings.wake.command,
)
# Reset # Reset
from_client_task = None from_client_task = None
@@ -773,11 +817,7 @@ 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."""
wake_names: Optional[List[str]] = None await self.event_to_wake(Detect(names=self._wake_names).event())
if self.settings.wake.names:
wake_names = [w.name for w in self.settings.wake.names]
await self.event_to_wake(Detect(names=wake_names).event())
await self.trigger_detect() await self.trigger_detect()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -852,6 +892,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:
@@ -886,7 +948,10 @@ class SatelliteBase:
event_client = self._make_event_client() event_client = self._make_event_client()
assert event_client is not None assert event_client is not None
await event_client.connect() await event_client.connect()
_LOGGER.debug("Connected to event service") _LOGGER.info(
"Connected to event service: %s",
self.settings.event.uri or self.settings.event.command,
)
await event_client.write_event(event) await event_client.write_event(event)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -935,10 +1000,17 @@ class AlwaysStreamingSatellite(SatelliteBase):
elif PauseSatellite.is_type(event.type): elif PauseSatellite.is_type(event.type):
self.is_streaming = False self.is_streaming = False
_LOGGER.info("Satellite paused") _LOGGER.info("Satellite paused")
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
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()
# Wake word detected
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif Transcript.is_type(event.type) or Error.is_type(event.type): elif Transcript.is_type(event.type) or Error.is_type(event.type):
# Stop debug recording # Stop debug recording
if self.stt_audio_writer is not None: if self.stt_audio_writer is not None:
@@ -1010,10 +1082,16 @@ class VadStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type): if RunSatellite.is_type(event.type):
self._is_paused = False self._is_paused = False
_LOGGER.info("Waiting for speech") _LOGGER.info("Waiting for speech")
elif Detect.is_type(event.type):
# Wake word detection started
await self.trigger_detect()
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()
_LOGGER.debug("Wake word detected")
await self.trigger_detection(Detection.from_event(event))
elif ( elif (
Transcript.is_type(event.type) Transcript.is_type(event.type)
or Error.is_type(event.type) or Error.is_type(event.type)
@@ -1150,8 +1228,17 @@ class WakeStreamingSatellite(SatelliteBase):
# wake word id -> seconds # wake word id -> seconds
self.refractory_timestamp: Dict[Optional[str], float] = {} self.refractory_timestamp: Dict[Optional[str], float] = {}
# Local VAD for detecting the end of a voice command.
# If this is disabled, the server is expected to do it remotely.
self.vad: Optional[SileroVad] = None
if settings.vad.enabled: if settings.vad.enabled:
_LOGGER.warning("VAD is enabled but will not be used") _LOGGER.debug("Loading VAD model")
self.vad = SileroVad(threshold=settings.vad.threshold, trigger_level=1)
_LOGGER.info("Loaded VAD model")
self.segmenter = VoiceCommandSegmenter(
silence_seconds=settings.vad.finished_speaking_seconds
)
# Used for debug audio recording so both wake and stt WAV files have the # Used for debug audio recording so both wake and stt WAV files have the
# same timestamp. # same timestamp.
@@ -1162,6 +1249,9 @@ class WakeStreamingSatellite(SatelliteBase):
self._wake_info: Optional[Info] = None self._wake_info: Optional[Info] = None
self._wake_info_ready = asyncio.Event() self._wake_info_ready = asyncio.Event()
self._run_pipeline_queue: Queue[RunPipeline] = Queue()
self._next_run_pipeline: Optional[RunPipeline] = None
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
@@ -1172,13 +1262,42 @@ class WakeStreamingSatellite(SatelliteBase):
if RunSatellite.is_type(event.type): if RunSatellite.is_type(event.type):
is_run_satellite = True is_run_satellite = True
self._is_paused = False self._is_paused = False
elif PauseSatellite.is_type(event.type): elif PauseSatellite.is_type(event.type):
is_pause_satellite = True is_pause_satellite = True
elif Detect.is_type(event.type):
# Request to change active wake words
detect = Detect.from_event(event)
self._wake_names = detect.names
if not self.is_streaming:
await self._send_wake_detect()
_LOGGER.debug("Updated wake word names: %s", self._wake_names)
elif Transcript.is_type(event.type): elif Transcript.is_type(event.type):
is_transcript = True is_transcript = True
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.ASR
):
# Done with remotely triggered pipeline
self._next_run_pipeline = None
elif Error.is_type(event.type): elif Error.is_type(event.type):
is_error = True is_error = True
elif RunPipeline.is_type(event.type):
run_pipeline = RunPipeline.from_event(event)
if not self.is_streaming:
# Run right now
self._next_run_pipeline = run_pipeline
await self._trigger_pipeline(run_pipeline)
return
# Run after streaming is finished
self._run_pipeline_queue.put(run_pipeline)
elif AudioStop.is_type(event.type):
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.TTS
):
# Done with remotely triggered pipeline
self._next_run_pipeline = None
if is_transcript or is_pause_satellite: 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
@@ -1199,20 +1318,20 @@ class WakeStreamingSatellite(SatelliteBase):
self._is_paused = True self._is_paused = True
_LOGGER.debug("Satellite is paused") _LOGGER.debug("Satellite is paused")
else: else:
# Go back to wake word detection # Go back to wake word detection or run next pipeline
await self.trigger_streaming_stop() await self.trigger_streaming_stop()
# It's possible to be paused in the middle of streaming # It's possible to be paused in the middle of streaming
if not self._is_paused: if self._is_paused:
await self._send_wake_detect() return
_LOGGER.info("Waiting for wake word")
# Start debug recording (wake) if self._run_pipeline_queue.empty():
self._debug_recording_timestamp = time.monotonic_ns() # Back to wake word detection
if self.wake_audio_writer is not None: await self._start_wake_word_detection()
self.wake_audio_writer.start( else:
timestamp=self._debug_recording_timestamp # Next run from queue
) self._next_run_pipeline = self._run_pipeline_queue.get()
await self._trigger_pipeline(self._next_run_pipeline)
async def trigger_server_disonnected(self) -> None: async def trigger_server_disonnected(self) -> None:
await super().trigger_server_disonnected() await super().trigger_server_disonnected()
@@ -1248,13 +1367,26 @@ class WakeStreamingSatellite(SatelliteBase):
self.stt_audio_writer.write(audio_bytes) self.stt_audio_writer.write(audio_bytes)
if self.is_streaming: if self.is_streaming:
if self.vad is not None:
if audio_bytes is None:
chunk = AudioChunk.from_event(event)
audio_bytes = chunk.audio
is_speech = self.vad(audio_bytes)
chunk_seconds = (len(audio_bytes) // 2) / 16000
if not self.segmenter.process(chunk_seconds, is_speech):
# Send empty audio chunk to indicate command is done
event = AudioChunk(
rate=16000, width=2, channels=1, audio=bytes()
).event()
# Forward to server # Forward to server
await self.event_to_server(event) await self.event_to_server(event)
else: else:
# Forward to wake word service # Forward to wake word service
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, is_fake: bool = False) -> None:
if Info.is_type(event.type): if Info.is_type(event.type):
self._wake_info = Info.from_event(event) self._wake_info = Info.from_event(event)
self._wake_info_ready.set() self._wake_info_ready.set()
@@ -1264,9 +1396,34 @@ class WakeStreamingSatellite(SatelliteBase):
# Not detecting or no server connected # Not detecting or no server connected
return return
if Detection.is_type(event.type): if not Detection.is_type(event.type):
return
detection = Detection.from_event(event) detection = Detection.from_event(event)
if (self._next_run_pipeline is not None) and (
self._next_run_pipeline.end_stage == PipelineStage.WAKE
):
# Remote pipeline was triggered with only wake word detection
wake_word_name: Optional[str] = None
if detection.name:
wake_word_name = normalize_wake_word(detection.name)
if (not self._next_run_pipeline.wake_word_names) or (
wake_word_name in self._next_run_pipeline.wake_word_names
):
self._next_run_pipeline = None
await self.event_to_server(event)
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
else:
_LOGGER.debug(
"Got %s but listening for %s",
wake_word_name,
self._next_run_pipeline.wake_word_names,
)
return
# Check refractory period to avoid multiple back-to-back detections # Check refractory period to avoid multiple back-to-back detections
refractory_timestamp = self.refractory_timestamp.get(detection.name) refractory_timestamp = self.refractory_timestamp.get(detection.name)
if (refractory_timestamp is not None) and ( if (refractory_timestamp is not None) and (
@@ -1285,10 +1442,11 @@ class WakeStreamingSatellite(SatelliteBase):
_LOGGER.debug(detection) _LOGGER.debug(detection)
self.segmenter.reset()
self.is_streaming = True self.is_streaming = True
_LOGGER.debug("Streaming audio") _LOGGER.debug("Streaming audio")
if self.settings.wake.refractory_seconds is not None: if (not is_fake) and (self.settings.wake.refractory_seconds is not None):
# Another detection may not occur for this wake word until # Another detection may not occur for this wake word until
# refractory period is over. # refractory period is over.
self.refractory_timestamp[detection.name] = ( self.refractory_timestamp[detection.name] = (
@@ -1298,19 +1456,25 @@ class WakeStreamingSatellite(SatelliteBase):
# No refractory period # No refractory period
self.refractory_timestamp.pop(detection.name, None) self.refractory_timestamp.pop(detection.name, None)
if not is_fake:
# Forward to the server # Forward to the server
await self.event_to_server(event) await self.event_to_server(event)
# Match detected wake word name with pipeline name start_stage: Optional[PipelineStage] = None
pipeline_name: Optional[str] = None end_stage: Optional[PipelineStage] = 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) if self._next_run_pipeline is not None:
# Remote pipeline has been triggered
start_stage = self._next_run_pipeline.start_stage
end_stage = self._next_run_pipeline.end_stage
await self._send_run_pipeline(
start_stage=start_stage,
end_stage=end_stage,
wake_word_name=detection.name,
)
if not is_fake:
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()
@@ -1330,3 +1494,34 @@ class WakeStreamingSatellite(SatelliteBase):
info.wake = self._wake_info.wake info.wake = self._wake_info.wake
except asyncio.TimeoutError: except asyncio.TimeoutError:
_LOGGER.warning("Failed to get info from wake service") _LOGGER.warning("Failed to get info from wake service")
async def _start_wake_word_detection(self) -> None:
await self._send_wake_detect()
_LOGGER.info("Waiting for wake word")
# Start debug recording (wake)
self._debug_recording_timestamp = time.monotonic_ns()
if self.wake_audio_writer is not None:
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp)
async def _trigger_pipeline(self, run_pipeline: RunPipeline) -> None:
_LOGGER.debug("Pipeline remotely triggered: %s", run_pipeline)
if run_pipeline.start_stage == PipelineStage.WAKE:
if run_pipeline.wake_word_names:
run_pipeline.wake_word_names = [
normalize_wake_word(name) for name in run_pipeline.wake_word_names
]
_LOGGER.debug(
"Listening once for wake words: %s", run_pipeline.wake_word_names
)
await self.event_to_wake(Detect(names=run_pipeline.wake_word_names).event())
elif run_pipeline.start_stage == PipelineStage.ASR:
_LOGGER.debug("Listening for voice command once")
# Fake a detection
await self.event_from_wake(Detection().event(), is_fake=True)
elif run_pipeline.start_stage == PipelineStage.TTS:
_LOGGER.debug("Speaking once: %s", run_pipeline.announce_text)
await self.event_to_server(run_pipeline.event())
+32 -9
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
@@ -103,19 +104,11 @@ 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[WakeWordAndPipeline]] = None names: Optional[List[str]] = None
"""List of wake word names to listen for.""" """List of wake word names to listen for."""
rate: int = 16000 rate: int = 16000
@@ -150,6 +143,9 @@ class VadSettings:
wake_word_timeout: Optional[float] = 5.0 wake_word_timeout: Optional[float] = 5.0
"""Seconds before going back to sleep if wake word is not detected.""" """Seconds before going back to sleep if wake word is not detected."""
finished_speaking_seconds: float = 0.8
"""Seconds to wait after speech before voice command is finished."""
@dataclass(frozen=True) @dataclass(frozen=True)
class EventSettings(ServiceSettings): class EventSettings(ServiceSettings):
@@ -172,6 +168,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 +203,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
+1 -1
View File
@@ -10,7 +10,7 @@ from pyring_buffer import RingBuffer
from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.event import Event from wyoming.event import Event
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger(__name__)
class AudioBuffer: class AudioBuffer:
+11 -3
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
_LOGGER = logging.getLogger() from wyoming.event import Eventable
_LOGGER = logging.getLogger(__name__)
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(
+96
View File
@@ -1,6 +1,10 @@
"""Voice activity detection.""" """Voice activity detection."""
import logging
from dataclasses import dataclass
from typing import Optional from typing import Optional
_LOGGER = logging.getLogger(__name__)
class SileroVad: class SileroVad:
"""Voice activity detection with silero VAD.""" """Voice activity detection with silero VAD."""
@@ -31,3 +35,95 @@ class SileroVad:
self._activation = max(0, self._activation - 1) self._activation = max(0, self._activation - 1)
return False return False
@dataclass
class VoiceCommandSegmenter:
"""Segments an audio stream into voice commands."""
speech_seconds: float = 0.3
"""Seconds of speech before voice command has started."""
silence_seconds: float = 1.0
"""Seconds of silence after voice command has ended."""
timeout_seconds: float = 15.0
"""Maximum number of seconds before stopping with timeout=True."""
reset_seconds: float = 1.0
"""Seconds before reset start/stop time counters."""
in_command: bool = False
"""True if inside voice command."""
_speech_seconds_left: float = 0.0
"""Seconds left before considering voice command as started."""
_silence_seconds_left: float = 0.0
"""Seconds left before considering voice command as stopped."""
_timeout_seconds_left: float = 0.0
"""Seconds left before considering voice command timed out."""
_reset_seconds_left: float = 0.0
"""Seconds left before resetting start/stop time counters."""
def __post_init__(self) -> None:
"""Reset after initialization."""
self.reset()
def reset(self) -> None:
"""Reset all counters and state."""
self._speech_seconds_left = self.speech_seconds
self._silence_seconds_left = self.silence_seconds
self._timeout_seconds_left = self.timeout_seconds
self._reset_seconds_left = self.reset_seconds
self.in_command = False
def process(self, chunk_seconds: float, is_speech: bool | None) -> bool:
"""Process samples using external VAD.
Returns False when command is done.
"""
self._timeout_seconds_left -= chunk_seconds
if self._timeout_seconds_left <= 0:
_LOGGER.warning(
"VAD end of speech detection timed out after %s seconds",
self.timeout_seconds,
)
self.reset()
return False
if not self.in_command:
if is_speech:
self._reset_seconds_left = self.reset_seconds
self._speech_seconds_left -= chunk_seconds
if self._speech_seconds_left <= 0:
# Inside voice command
self.in_command = True
self._silence_seconds_left = self.silence_seconds
_LOGGER.debug("Voice command started")
else:
# Reset if enough silence
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._speech_seconds_left = self.speech_seconds
self._reset_seconds_left = self.reset_seconds
elif not is_speech:
# Silence in command
self._reset_seconds_left = self.reset_seconds
self._silence_seconds_left -= chunk_seconds
if self._silence_seconds_left <= 0:
# Command finished successfully
self.reset()
_LOGGER.debug("Voice command finished")
return False
else:
# Speech in command.
# Reset silence counter if enough speech.
self._reset_seconds_left -= chunk_seconds
if self._reset_seconds_left <= 0:
self._silence_seconds_left = self.silence_seconds
self._reset_seconds_left = self.reset_seconds
return True