From 33e6d80786ed888ecb662dcdf597ef0cb2c67937 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 15 Jan 2024 15:14:10 -0600 Subject: [PATCH] Add ping --- README.md | 2 + examples/2mic_service.py | 16 +++++- examples/4mic_service.py | 16 +++++- installer/const.py | 1 + installer/satellite.py | 48 ++++++++++++---- installer/services.py | 23 ++++++++ script/format | 3 +- script/run_2mic | 15 +++++ script/run_4mic | 15 +++++ setup.py | 2 +- wyoming_satellite/__main__.py | 12 +++- wyoming_satellite/event_handler.py | 4 +- wyoming_satellite/satellite.py | 89 +++++++++++++++++++++++++++--- wyoming_satellite/settings.py | 2 + 14 files changed, 224 insertions(+), 24 deletions(-) create mode 100755 script/run_2mic create mode 100755 script/run_4mic diff --git a/README.md b/README.md index b9c0355..23ce508 100644 --- a/README.md +++ b/README.md @@ -176,5 +176,7 @@ Satellites can respond to events from the server by running commands: * `--tts-start-command` - text-to-speech response started (no stdin) * `--tts-stop-command` - text-to-speech response stopped (no stdin) * `--error-command` - an error was sent from the server (text on stdin) +* `--connected-command` - satellite connected to server +* `--disconnected-command` - satellite disconnected from server 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/2mic_service.py b/examples/2mic_service.py index 807a6e4..62f3866 100644 --- a/examples/2mic_service.py +++ b/examples/2mic_service.py @@ -12,7 +12,13 @@ import gpiozero import spidev from wyoming.asr import Transcript from wyoming.event import Event -from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped +from wyoming.satellite import ( + RunSatellite, + StreamingStarted, + StreamingStopped, + SatelliteConnected, + SatelliteDisconnected, +) from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.vad import VoiceStarted from wyoming.wake import Detection @@ -107,6 +113,14 @@ class LEDsEventHandler(AsyncEventHandler): self.color(_BLACK) elif RunSatellite.is_type(event.type): self.color(_BLACK) + elif SatelliteConnected.is_type(event.type): + # Flash + for _ in range(5): + self.color(_GREEN) + await asyncio.sleep(0.3) + self.color(_BLACK) + elif SatelliteDisconnected.is_type(event.type): + self.color(_RED) return True diff --git a/examples/4mic_service.py b/examples/4mic_service.py index c83da12..8beb418 100644 --- a/examples/4mic_service.py +++ b/examples/4mic_service.py @@ -12,7 +12,13 @@ import gpiozero import spidev from wyoming.asr import Transcript from wyoming.event import Event -from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped +from wyoming.satellite import ( + RunSatellite, + StreamingStarted, + StreamingStopped, + SatelliteConnected, + SatelliteDisconnected, +) from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.vad import VoiceStarted from wyoming.wake import Detection @@ -107,6 +113,14 @@ class LEDsEventHandler(AsyncEventHandler): self.color(_BLACK) elif RunSatellite.is_type(event.type): self.color(_BLACK) + elif SatelliteConnected.is_type(event.type): + # Flash + for _ in range(5): + self.color(_GREEN) + await asyncio.sleep(0.3) + self.color(_BLACK) + elif SatelliteDisconnected.is_type(event.type): + self.color(_RED) return True diff --git a/installer/const.py b/installer/const.py index ef49e96..3697890 100644 --- a/installer/const.py +++ b/installer/const.py @@ -36,6 +36,7 @@ class SatelliteSettings(DataClassJsonMixin): name: str = "Wyoming Satellite" type: SatelliteType = SatelliteType.ALWAYS_STREAMING debug: bool = False + event_service_command: Optional[List[str]] = None @dataclass diff --git a/installer/satellite.py b/installer/satellite.py index d1f2be1..0a66201 100644 --- a/installer/satellite.py +++ b/installer/satellite.py @@ -1,7 +1,7 @@ """Satellite settings.""" from typing import Optional -from .const import SatelliteType, Settings +from .const import SatelliteType, Settings, PROGRAM_DIR from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge @@ -29,20 +29,22 @@ def configure_satellite(settings: Settings) -> None: if satellite_type is not None: settings.satellite.type = SatelliteType(satellite_type) settings.save() - elif choice in ("stop", "start"): + elif choice == "feedback": + configure_feedback(settings) + elif choice in ("restart", "stop", "start"): password = passwordbox("sudo password:") if not password: continue command = ["sudo", "-S", "systemctl", choice, "wyoming-satellite.service"] - text = ( - "Stopping satellite..." if choice == "stop" else "Starting satellite..." + text = {"restart": "Restarting", "stop": "Stopping", "start": "Starting"}[ + choice + ] + success = run_with_gauge( + f"{text} satellite...", [command], sudo_password=password ) - success = run_with_gauge(text, [command], sudo_password=password) if not success: - error( - "stopping satellite" if choice == "stop" else "starting satellite" - ) + error(f"{text.lower()} satellite") elif choice == "debug": debug = radiolist( "Debug Mode:", @@ -66,10 +68,36 @@ def satellite_menu(last_choice: Optional[str]) -> Optional[str]: [ ("name", "Satellite Name"), ("type", "Satellite Type"), - ("stop", "Stop Service"), - ("start", "Start Service"), + ("feedback", "Feedback"), + ("restart", "Restart Services"), + ("stop", "Stop Services"), + ("start", "Start Services"), ("debug", "Set Debug Mode"), ], selected_item=last_choice, menu_args=["--ok-button", "Select", "--cancel-button", "Back"], ) + + +def configure_feedback(settings: Settings) -> None: + choice: Optional[str] = None + while True: + choice = menu("Main > Satellite > Feedback", [("respeaker", "ReSpeaker")]) + if choice == "respeaker": + event_service = radiolist( + "Event Service:", + [ + ("2mic", "2mic LEDs"), + ("4mic", "4mic LEDs"), + ], + ) + + if event_service is not None: + settings.satellite.event_service_command = [ + str(PROGRAM_DIR / "script" / "run_{event_service}"), + "--uri", + "tcp://127.0.0.1:10500", + ] + settings.save() + else: + break diff --git a/installer/services.py b/installer/services.py index 1e82999..16b453f 100644 --- a/installer/services.py +++ b/installer/services.py @@ -158,6 +158,29 @@ def generate_services(settings: Settings) -> None: ) satellite_requires.append(f"{wake_word_service}.service") + if settings.satellite.event_service_command: + event_service = "wyoming-event" + event_command_str = shlex.join(settings.satellite.event_service_command) + with open( + SERVICES_DIR / f"{event_service}.service", "w", encoding="utf-8" + ) as service_file: + print("[Unit]", file=service_file) + print(f"Description=Event service", file=service_file) + print("", file=service_file) + print("[Service]", file=service_file) + print("Type=simple", file=service_file) + print(f"User={user_name}", file=service_file) + print(f"ExecStart={event_command_str}", file=service_file) + print(f"WorkingDirectory={PROGRAM_DIR}", file=service_file) + print("Restart=always", file=service_file) + print("RestartSec=1", file=service_file) + print("", file=service_file) + print("[Install]", file=service_file) + print("WantedBy=default.target", file=service_file) + + satellite_command.extend(["--event-uri", "tcp://127.0.0.1:10500"]) + satellite_requires.append(f"{event_service}.service") + if settings.satellite.debug: satellite_command.extend( ["--debug", "--debug-recording-dir", str(LOCAL_DIR / "debug-recording")] diff --git a/script/format b/script/format index 2bb48d6..24d4853 100755 --- a/script/format +++ b/script/format @@ -9,8 +9,9 @@ _VENV_DIR = _PROGRAM_DIR / ".venv" _MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite" _TESTS_DIR = _PROGRAM_DIR / "tests" _INSTALLER_DIR = _PROGRAM_DIR / "installer" +_EXAMPLES_DIR = _PROGRAM_DIR / "examples" -_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR] +_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR, _INSTALLER_DIR, _EXAMPLES_DIR] context = venv.EnvBuilder().ensure_directories(_VENV_DIR) subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS) diff --git a/script/run_2mic b/script/run_2mic new file mode 100755 index 0000000..c94f2a2 --- /dev/null +++ b/script/run_2mic @@ -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" / "2mic_service.py")] + + sys.argv[1:] +) diff --git a/script/run_4mic b/script/run_4mic new file mode 100755 index 0000000..951c8c6 --- /dev/null +++ b/script/run_4mic @@ -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" / "4mic_service.py")] + + sys.argv[1:] +) diff --git a/setup.py b/setup.py index 62dd88e..eab1ae6 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ extras_require = { setup( name="wyoming_satellite", - version="1.0.1", + version="1.1.0", description="Wyoming server for remote voice satellite", url="http://github.com/rhasspy/wyoming-satellite", author="Michael Hansen", diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index cd50634..140e423 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -193,6 +193,14 @@ async def main() -> None: "--error-command", help="Command to run when an error occurs", ) + parser.add_argument( + "--connected-command", + help="Command to run when connected to the server", + ) + parser.add_argument( + "--disconnected-command", + help="Command to run when disconnected from the server", + ) # Sounds parser.add_argument( @@ -326,6 +334,8 @@ async def main() -> None: tts_start=split_command(args.tts_start_command), tts_stop=split_command(args.tts_stop_command), error=split_command(args.error_command), + connected=split_command(args.connected_command), + disconnected=split_command(args.disconnected_command), ), debug_recording_dir=args.debug_recording_dir, ) @@ -368,7 +378,7 @@ async def main() -> None: args.zeroconf_host, ) - satellite_task = asyncio.create_task(satellite.run()) + satellite_task = asyncio.create_task(satellite.run(), name="satellite run") try: await server.run(partial(SatelliteEventHandler, wyoming_info, satellite, args)) diff --git a/wyoming_satellite/event_handler.py b/wyoming_satellite/event_handler.py index bff3a67..f68179b 100644 --- a/wyoming_satellite/event_handler.py +++ b/wyoming_satellite/event_handler.py @@ -40,7 +40,7 @@ class SatelliteEventHandler(AsyncEventHandler): if self.satellite.server_id is None: # Take over after a problem occurred - self.satellite.set_server(self.client_id, self.writer) + await self.satellite.set_server(self.client_id, self.writer) elif self.satellite.server_id != self.client_id: # New connection _LOGGER.debug("Connection cancelled: %s", self.client_id) @@ -53,4 +53,4 @@ class SatelliteEventHandler(AsyncEventHandler): async def disconnect(self) -> None: """Server disconnect.""" if self.satellite.server_id == self.client_id: - self.satellite.clear_server() + await self.satellite.clear_server() diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index a10f185..ec66f86 100644 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -5,7 +5,7 @@ import math import time from enum import Enum, auto from pathlib import Path -from typing import Callable, Dict, Optional, Set, Union +from typing import Callable, Dict, Optional, Set, Union, Final from pyring_buffer import RingBuffer from wyoming.asr import Transcript @@ -15,11 +15,18 @@ from wyoming.error import Error from wyoming.event import Event, async_write_event from wyoming.mic import MicProcessAsyncClient from wyoming.pipeline import PipelineStage, RunPipeline -from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped +from wyoming.satellite import ( + RunSatellite, + StreamingStarted, + StreamingStopped, + SatelliteConnected, + SatelliteDisconnected, +) from wyoming.snd import SndProcessAsyncClient from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection, WakeProcessAsyncClient +from wyoming.ping import Ping, Pong from .settings import SatelliteSettings from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events @@ -28,6 +35,9 @@ from .webrtc import WebRtcAudio _LOGGER = logging.getLogger() +_PONG_TIMEOUT: Final = 5 +_PING_SEND_DELAY: Final = 2 + class State(Enum): NOT_STARTED = auto() @@ -60,6 +70,9 @@ class SatelliteBase: self._event_task: Optional[asyncio.Task] = None self._event_queue: "Optional[asyncio.Queue[Event]]" = None + self._pong_received_event = asyncio.Event() + self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping") + # Debug audio recording self.wake_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None @@ -120,17 +133,19 @@ class SatelliteBase: while self.state != State.STOPPED: await self._state_changed.wait() - def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None: + async def set_server(self, server_id: str, writer: asyncio.StreamWriter) -> None: """Set event writer.""" self.server_id = server_id self._writer = writer _LOGGER.debug("Server set: %s", server_id) + await self.trigger_server_connected() - def clear_server(self) -> None: + async def clear_server(self) -> None: """Remove writer.""" self.server_id = None self._writer = None _LOGGER.debug("Server disconnected") + await self.trigger_server_disonnected() async def event_to_server(self, event: Event) -> None: """Send an event to the server.""" @@ -147,6 +162,31 @@ class SatelliteBase: else: _LOGGER.exception("Unexpected error sending event to server") + async def _ping_server(self) -> None: + try: + while self.is_running: + await asyncio.sleep(_PING_SEND_DELAY) + if self.server_id is None: + # No server connected + continue + + # Send ping and wait for pong + self._pong_received_event.clear() + await self.event_to_server(Ping().event()) + try: + await asyncio.wait_for( + self._pong_received_event.wait(), timeout=_PONG_TIMEOUT + ) + except asyncio.TimeoutError: + if self.server_id is None: + # No server connected + continue + + _LOGGER.warning("Did not receive ping response within timeout") + await self.clear_server() + except Exception: + _LOGGER.exception("Unexpected error in ping server task") + # ------------------------------------------------------------------------- async def _start(self) -> None: @@ -172,7 +212,14 @@ class SatelliteBase: async def event_from_server(self, event: Event) -> None: """Called when an event is received from the server.""" - if AudioChunk.is_type(event.type): + if Ping.is_type(event.type): + # Respond with pong + ping = Ping.from_event(event) + await self.event_to_server(Pong(text=ping.text).event()) + elif Pong.is_type(event.type): + # Response from our ping + self._pong_received_event.set() + elif AudioChunk.is_type(event.type): # TTS audio await self.event_to_snd(event) elif AudioStart.is_type(event.type): @@ -217,9 +264,11 @@ class SatelliteBase: if self.settings.wake.enabled: # Local wake word detection start_stage = PipelineStage.ASR + restart_on_end = False else: # Remote wake word detection start_stage = PipelineStage.WAKE + restart_on_end = not self.settings.vad.enabled if self.settings.snd.enabled: # Play TTS response @@ -228,7 +277,9 @@ class SatelliteBase: # No audio output end_stage = PipelineStage.HANDLE - run_pipeline = RunPipeline(start_stage=start_stage, end_stage=end_stage).event() + run_pipeline = RunPipeline( + start_stage=start_stage, end_stage=end_stage, restart_on_end=restart_on_end + ).event() await self.event_to_server(run_pipeline) await self.forward_event(run_pipeline) @@ -639,6 +690,18 @@ class SatelliteBase: # Events # ------------------------------------------------------------------------- + async def trigger_server_connected(self) -> None: + """Called when connected to server.""" + _LOGGER.info("Connected to server") + await run_event_command(self.settings.event.connected) + await self.forward_event(SatelliteConnected().event()) + + async def trigger_server_disonnected(self) -> None: + """Called when disconnected from server.""" + _LOGGER.info("Disconnected from server") + await run_event_command(self.settings.event.disconnected) + await self.forward_event(SatelliteDisconnected().event()) + async def trigger_streaming_start(self) -> None: """Called when audio streaming starts.""" await run_event_command(self.settings.event.streaming_start) @@ -997,6 +1060,17 @@ class WakeStreamingSatellite(SatelliteBase): if self.wake_audio_writer is not None: self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp) + async def trigger_server_disonnected(self) -> None: + await super().trigger_server_disonnected() + + self.is_streaming = False + + # Stop debug recording (stt) + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + await self.trigger_streaming_stop() + async def event_from_mic( self, event: Event, audio_bytes: Optional[bytes] = None ) -> None: @@ -1023,7 +1097,8 @@ class WakeStreamingSatellite(SatelliteBase): await self.event_to_wake(event) async def event_from_wake(self, event: Event) -> None: - if self.is_streaming: + if self.is_streaming or (self.server_id is None): + # Not streaming or no server connected return if Detection.is_type(event.type): diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index 058d0a8..c9fdcf9 100644 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -150,6 +150,8 @@ class EventSettings(ServiceSettings): tts_start: Optional[List[str]] = None tts_stop: Optional[List[str]] = None error: Optional[List[str]] = None + connected: Optional[List[str]] = None + disconnected: Optional[List[str]] = None @dataclass(frozen=True)