Add ping
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
-10
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Executable
+15
@@ -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:]
|
||||
)
|
||||
Executable
+15
@@ -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:]
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user