diff --git a/.gitignore b/.gitignore index f0c0a12..8798bff 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ tmp/ build htmlcov -/.venv/ +.venv/ .mypy_cache/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d811a3..0f0e976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.3.0 + +- Bump to wyoming 1.5.4 (timers) +- Add support for voice timers +- Add `--timer-finished-wav` and `--timer-finished-wav-repeat` +- Add `--timer-started-command` +- Add `--timer-updated-command` +- Add `--timer-cancelled-command` +- Add `--timer-finished-command` + ## 1.2.0 - Add `--tts-played-command` diff --git a/README.md b/README.md index 3c4ba64..e82c9dd 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,12 @@ You can play a WAV file when the wake word is detected (locally or remotely), an * `--awake-wav ` - played when the wake word is detected * `--done-wav ` - played when the voice command is finished +* `--timer-finished-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`. +The timer finished sound can be repeated with `--timer-finished-wav-repeat ` where `` is the number of times to repeat the WAV, and `` is the number of seconds to wait between repeats. + ## Audio Enhancements 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) * `--connected-command` - satellite connected to 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. diff --git a/examples/websocket-service/requirements.txt b/examples/websocket-service/requirements.txt new file mode 100644 index 0000000..02e1832 --- /dev/null +++ b/examples/websocket-service/requirements.txt @@ -0,0 +1 @@ +websockets==12.0 diff --git a/examples/websocket-service/server.py b/examples/websocket-service/server.py new file mode 100644 index 0000000..6fbf84c --- /dev/null +++ b/examples/websocket-service/server.py @@ -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 diff --git a/examples/websocket-service/timer_finished.wav b/examples/websocket-service/timer_finished.wav new file mode 100644 index 0000000..40a97bc Binary files /dev/null and b/examples/websocket-service/timer_finished.wav differ diff --git a/examples/websocket-service/timers.html b/examples/websocket-service/timers.html new file mode 100644 index 0000000..0c6777b --- /dev/null +++ b/examples/websocket-service/timers.html @@ -0,0 +1,244 @@ + + + + + + + + + + + Wyoming Timer Example + + + + +
+
+ + + + + diff --git a/requirements.txt b/requirements.txt index 1eb7bdf..a1bbfef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -wyoming==1.5.3 +wyoming==1.5.4 zeroconf==0.88.0 pyring-buffer==1.0.0 diff --git a/sounds/timer_finished.wav b/sounds/timer_finished.wav new file mode 100644 index 0000000..40a97bc Binary files /dev/null and b/sounds/timer_finished.wav differ diff --git a/wyoming_satellite/VERSION b/wyoming_satellite/VERSION index 26aaba0..f0bb29e 100644 --- a/wyoming_satellite/VERSION +++ b/wyoming_satellite/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index ed29a6f..f878081 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -1,4 +1,5 @@ """Main entry point for Wyoming satellite.""" + import argparse import asyncio import logging @@ -22,6 +23,7 @@ MicSettings, SatelliteSettings, SndSettings, + TimerSettings, VadSettings, WakeSettings, WakeWordAndPipeline, @@ -225,6 +227,23 @@ async def main() -> None: "--disconnected-command", 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 parser.add_argument( @@ -233,6 +252,17 @@ async def main() -> None: parser.add_argument( "--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 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) 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): _LOGGER.warning("VAD is not used with local wake word detection") @@ -347,9 +381,11 @@ async def main() -> None: names=[ WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name ], - refractory_seconds=args.wake_refractory_seconds - if args.wake_refractory_seconds > 0 - else None, + refractory_seconds=( + args.wake_refractory_seconds + if args.wake_refractory_seconds > 0 + else None + ), ), snd=SndSettings( uri=args.snd_uri, @@ -379,6 +415,15 @@ async def main() -> None: connected=split_command(args.connected_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, ) diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index 6102254..c798167 100644 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -28,6 +28,7 @@ StreamingStopped, ) from wyoming.snd import Played, SndProcessAsyncClient +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection, WakeProcessAsyncClient @@ -242,6 +243,8 @@ async def stopped(self) -> None: async def event_from_server(self, event: Event) -> None: """Called when an event is received from the server.""" + forward_event = True + if Ping.is_type(event.type): # Respond with pong ping = Ping.from_event(event) @@ -251,12 +254,16 @@ async def event_from_server(self, event: Event) -> None: # Enable pinging self._enable_ping() _LOGGER.debug("Ping enabled") + + forward_event = False elif Pong.is_type(event.type): # Response from our ping self._pong_received_event.set() + forward_event = False elif AudioChunk.is_type(event.type): # TTS audio await self.event_to_snd(event) + forward_event = False elif AudioStart.is_type(event.type): # TTS started await self.event_to_snd(event) @@ -289,9 +296,21 @@ async def event_from_server(self, event: Event) -> None: elif Error.is_type(event.type): _LOGGER.warning(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 - if not AudioChunk.is_type(event.type): + # Forward everything except audio/ping/pong to event service + if forward_event: await self.forward_event(event) async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None: @@ -852,6 +871,28 @@ async def trigger_error(self, error: Error) -> None: """Called when an error occurs on the server.""" 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: """Forward an event to the event service.""" if self._event_queue is not None: diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index 903ff27..fd9c959 100644 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -1,4 +1,5 @@ """Satellite settings.""" + from abc import ABC from dataclasses import dataclass, field from pathlib import Path @@ -172,6 +173,32 @@ class EventSettings(ServiceSettings): 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) class SatelliteSettings: """Wyoming satellite settings.""" @@ -181,6 +208,7 @@ class SatelliteSettings: wake: WakeSettings = field(default_factory=WakeSettings) snd: SndSettings = field(default_factory=SndSettings) event: EventSettings = field(default_factory=EventSettings) + timer: TimerSettings = field(default_factory=TimerSettings) restart_timeout: float = 5.0 diff --git a/wyoming_satellite/utils/misc.py b/wyoming_satellite/utils/misc.py index 5d39d14..0c8a7c5 100644 --- a/wyoming_satellite/utils/misc.py +++ b/wyoming_satellite/utils/misc.py @@ -1,24 +1,32 @@ """Miscellaneous utilities.""" import argparse import asyncio +import json import logging import re import shlex import unicodedata import uuid from functools import lru_cache -from typing import List, Optional +from typing import List, Optional, Union + +from wyoming.event import Eventable _LOGGER = logging.getLogger() 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: """Run a custom event command with optional input.""" if not command: 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) program, *program_args = command proc = await asyncio.create_subprocess_exec(