This commit is contained in:
Michael Hansen
2024-01-16 17:06:30 -06:00
parent db5918e04a
commit 33e6d80786
14 changed files with 224 additions and 24 deletions
+2
View File
@@ -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.
+15 -1
View File
@@ -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
+15 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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)
+15
View File
@@ -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:]
)
+15
View File
@@ -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:]
)
+1 -1
View File
@@ -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",
+11 -1
View File
@@ -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))
+2 -2
View File
@@ -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()
+82 -7
View File
@@ -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):
+2
View File
@@ -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)