diff --git a/wyoming_satellite/__init__.py b/wyoming_satellite/__init__.py index e69de29..b83acf5 100644 --- a/wyoming_satellite/__init__.py +++ b/wyoming_satellite/__init__.py @@ -0,0 +1 @@ +"""Voice satellite using the Wyoming protocol.""" diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index b96665d..4805b77 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -1,19 +1,15 @@ -#!/usr/bin/env python3 +"""Main entry point for Wyoming satellite.""" import argparse import asyncio import logging -import shlex import sys -import time -import uuid from functools import partial from pathlib import Path -from typing import List, Optional -from wyoming.event import Event -from wyoming.info import Attribution, Describe, Info, Satellite -from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer +from wyoming.info import Attribution, Info, Satellite +from wyoming.server import AsyncServer, AsyncTcpServer +from .event_handler import SatelliteEventHandler from .satellite import ( AlwaysStreamingSatellite, SatelliteBase, @@ -28,7 +24,13 @@ from .settings import ( VadSettings, WakeSettings, ) -from .utils import run_event_command +from .utils import ( + get_mac_address, + needs_silero, + needs_webrtc, + run_event_command, + split_command, +) _LOGGER = logging.getLogger() _DIR = Path(__file__).parent @@ -263,7 +265,7 @@ async def main() -> None: settings = SatelliteSettings( mic=MicSettings( uri=args.mic_uri, - command=_split(args.mic_command), + command=split_command(args.mic_command), rate=args.mic_command_rate, width=args.mic_command_width, channels=args.mic_command_channels, @@ -281,12 +283,12 @@ async def main() -> None: ), wake=WakeSettings( uri=args.wake_uri, - command=_split(args.wake_command), + command=split_command(args.wake_command), names=args.wake_word_name, ), snd=SndSettings( uri=args.snd_uri, - command=_split(args.snd_command), + command=split_command(args.snd_command), rate=args.snd_command_rate, width=args.snd_command_width, channels=args.snd_command_channels, @@ -296,18 +298,18 @@ async def main() -> None: ), event=EventSettings( uri=args.event_uri, - startup=_split(args.startup_command), - streaming_start=_split(args.streaming_start_command), - streaming_stop=_split(args.streaming_stop_command), - detect=_split(args.detect_command), - detection=_split(args.detection_command), - transcript=_split(args.transcript_command), - stt_start=_split(args.stt_start_command), - stt_stop=_split(args.stt_stop_command), - synthesize=_split(args.synthesize_command), - tts_start=_split(args.tts_start_command), - tts_stop=_split(args.tts_stop_command), - error=_split(args.error_command), + startup=split_command(args.startup_command), + streaming_start=split_command(args.streaming_start_command), + streaming_stop=split_command(args.streaming_stop_command), + detect=split_command(args.detect_command), + detection=split_command(args.detection_command), + transcript=split_command(args.transcript_command), + stt_start=split_command(args.stt_start_command), + stt_stop=split_command(args.stt_stop_command), + synthesize=split_command(args.synthesize_command), + tts_start=split_command(args.tts_start_command), + tts_stop=split_command(args.tts_stop_command), + error=split_command(args.error_command), ), ) @@ -324,7 +326,7 @@ async def main() -> None: satellite = AlwaysStreamingSatellite(settings) if args.startup_command: - await run_event_command(_split(args.startup_command)) + await run_event_command(split_command(args.startup_command)) _LOGGER.info("Ready") @@ -363,82 +365,6 @@ async def main() -> None: # ----------------------------------------------------------------------------- -class SatelliteEventHandler(AsyncEventHandler): - """Event handler for clients.""" - - def __init__( - self, - wyoming_info: Info, - satellite: SatelliteBase, - cli_args: argparse.Namespace, - *args, - **kwargs, - ) -> None: - super().__init__(*args, **kwargs) - - self.cli_args = cli_args - self.wyoming_info_event = wyoming_info.event() - self.client_id = str(time.monotonic_ns()) - self.satellite = satellite - - # ------------------------------------------------------------------------- - - async def handle_event(self, event: Event) -> bool: - """Handle events from the server.""" - if Describe.is_type(event.type): - await self.write_event(self.wyoming_info_event) - return True - - if self.satellite.server_id is None: - # Take over after a problem occurred - 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) - return False - - await self.satellite.event_from_server(event) - - return True - - async def disconnect(self) -> None: - """Server disconnect.""" - if self.satellite.server_id == self.client_id: - self.satellite.clear_server() - - -# ----------------------------------------------------------------------------- - - -def get_mac_address() -> str: - """Return MAC address formatted as hex with no colons.""" - return "".join( - # pylint: disable=consider-using-f-string - ["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][ - ::-1 - ] - ) - - -def needs_webrtc(args: argparse.Namespace) -> bool: - """Return True if webrtc must be used.""" - return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0) - - -def needs_silero(args: argparse.Namespace) -> bool: - """Return True if silero-vad must be used.""" - return args.vad - - -def _split(command: Optional[str]) -> Optional[List[str]]: - if not command: - return None - - return shlex.split(command) - - -# ----------------------------------------------------------------------------- - def run(): try: asyncio.run(main()) diff --git a/wyoming_satellite/event_handler.py b/wyoming_satellite/event_handler.py new file mode 100644 index 0000000..bff3a67 --- /dev/null +++ b/wyoming_satellite/event_handler.py @@ -0,0 +1,56 @@ +"""Wyoming event handler for satellites.""" +import argparse +import logging +import time + +from wyoming.event import Event +from wyoming.info import Describe, Info +from wyoming.server import AsyncEventHandler + +from .satellite import SatelliteBase + +_LOGGER = logging.getLogger() + + +class SatelliteEventHandler(AsyncEventHandler): + """Event handler for clients.""" + + def __init__( + self, + wyoming_info: Info, + satellite: SatelliteBase, + cli_args: argparse.Namespace, + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + self.cli_args = cli_args + self.wyoming_info_event = wyoming_info.event() + self.client_id = str(time.monotonic_ns()) + self.satellite = satellite + + # ------------------------------------------------------------------------- + + async def handle_event(self, event: Event) -> bool: + """Handle events from the server.""" + if Describe.is_type(event.type): + await self.write_event(self.wyoming_info_event) + return True + + if self.satellite.server_id is None: + # Take over after a problem occurred + 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) + return False + + await self.satellite.event_from_server(event) + + return True + + async def disconnect(self) -> None: + """Server disconnect.""" + if self.satellite.server_id == self.client_id: + self.satellite.clear_server() diff --git a/wyoming_satellite/utils/__init__.py b/wyoming_satellite/utils/__init__.py new file mode 100644 index 0000000..9092a71 --- /dev/null +++ b/wyoming_satellite/utils/__init__.py @@ -0,0 +1,21 @@ +"""Utility methods.""" +from .audio import AudioBuffer, chunk_samples, multiply_volume, wav_to_events +from .misc import ( + get_mac_address, + needs_silero, + needs_webrtc, + run_event_command, + split_command, +) + +__all__ = [ + "AudioBuffer", + "chunk_samples", + "get_mac_address", + "multiply_volume", + "needs_silero", + "needs_webrtc", + "run_event_command", + "split_command", + "wav_to_events", +] diff --git a/wyoming_satellite/utils.py b/wyoming_satellite/utils/audio.py similarity index 85% rename from wyoming_satellite/utils.py rename to wyoming_satellite/utils/audio.py index d080cd0..7f7d327 100644 --- a/wyoming_satellite/utils.py +++ b/wyoming_satellite/utils/audio.py @@ -1,10 +1,9 @@ -"""Utilities for Wyoming satellite.""" +"""Audio utilities.""" import array -import asyncio import logging import wave from pathlib import Path -from typing import Iterable, Iterator, List, Optional, Union +from typing import Iterable, Iterator, Union from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.event import Event @@ -98,27 +97,6 @@ def chunk_samples( leftover_chunk_buffer.append(rest_samples) -async def run_event_command( - command: Optional[List[str]], command_input: Optional[str] = None -) -> None: - """Run a custom event command with optional input.""" - if not command: - return - - _LOGGER.debug("Running %s", command) - program, *program_args = command - proc = await asyncio.create_subprocess_exec( - program, *program_args, stdin=asyncio.subprocess.PIPE - ) - assert proc.stdin is not None - - if command_input: - await proc.communicate(input=command_input.encode("utf-8")) - else: - proc.stdin.close() - await proc.wait() - - def wav_to_events( wav_path: Union[str, Path], samples_per_chunk: int = 1024, diff --git a/wyoming_satellite/utils/misc.py b/wyoming_satellite/utils/misc.py new file mode 100644 index 0000000..a5d59ff --- /dev/null +++ b/wyoming_satellite/utils/misc.py @@ -0,0 +1,58 @@ +"""Miscellaneous utilities.""" +import argparse +import asyncio +import logging +import shlex +import uuid +from typing import List, Optional + +_LOGGER = logging.getLogger() + + +async def run_event_command( + command: Optional[List[str]], command_input: Optional[str] = None +) -> None: + """Run a custom event command with optional input.""" + if not command: + return + + _LOGGER.debug("Running %s", command) + program, *program_args = command + proc = await asyncio.create_subprocess_exec( + program, *program_args, stdin=asyncio.subprocess.PIPE + ) + assert proc.stdin is not None + + if command_input: + await proc.communicate(input=command_input.encode("utf-8")) + else: + proc.stdin.close() + await proc.wait() + + +def get_mac_address() -> str: + """Return MAC address formatted as hex with no colons.""" + return "".join( + # pylint: disable=consider-using-f-string + ["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][ + ::-1 + ] + ) + + +def needs_webrtc(args: argparse.Namespace) -> bool: + """Return True if webrtc must be used.""" + return (args.mic_noise_suppression > 0) or (args.mic_auto_gain > 0) + + +def needs_silero(args: argparse.Namespace) -> bool: + """Return True if silero-vad must be used.""" + return args.vad + + +def split_command(command: Optional[str]) -> Optional[List[str]]: + """Split command line program/args if not empty.""" + if not command: + return None + + return shlex.split(command)