Author SHA1 Message Date
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 30e9d74c45 Download the respeaker driver for the kernel version in use 2024-05-18 11:20:51 +03:00
15 changed files with 504 additions and 13 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.
+10 -3
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'
@@ -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>
+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
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1.2.0 1.3.0
+48 -3
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://")
@@ -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=(
if args.wake_refractory_seconds > 0 args.wake_refractory_seconds
else None, if args.wake_refractory_seconds > 0
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,
) )
+43 -2
View File
@@ -28,6 +28,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
@@ -242,6 +243,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 +254,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 +296,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:
@@ -852,6 +871,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(