diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d811a3..798da0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.3.0 + +- Add support for voice timers + ## 1.2.0 - Add `--tts-played-command` 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 @@ from .settings import ( 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..6d5fa47 100644 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -28,6 +28,7 @@ from wyoming.satellite import ( 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 @@ -289,6 +290,18 @@ class SatelliteBase: 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): @@ -852,6 +865,28 @@ class SatelliteBase: """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(