From e01b6a21b99143f75e96c332a4c56f5b5398cd9d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Nov 2023 17:01:04 -0600 Subject: [PATCH] Initial commit --- .gitignore | 17 ++ .isort.cfg | 6 + .projectile | 1 + CHANGELOG.md | 6 + LICENSE.md | 21 ++ MANIFEST.in | 1 + README.md | 3 + mypy.ini | 5 + pylintrc | 37 +++ requirements.txt | 2 + requirements_dev.txt | 5 + script/format | 13 + script/lint | 16 ++ script/package | 11 + script/run | 12 + script/setup | 22 ++ setup.cfg | 22 ++ setup.py | 39 +++ wyoming_satellite/__init__.py | 0 wyoming_satellite/__main__.py | 331 ++++++++++++++++++++++ wyoming_satellite/example_event_client.py | 71 +++++ 21 files changed, 641 insertions(+) create mode 100644 .gitignore create mode 100644 .isort.cfg create mode 100644 .projectile create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 mypy.ini create mode 100644 pylintrc create mode 100644 requirements.txt create mode 100644 requirements_dev.txt create mode 100755 script/format create mode 100755 script/lint create mode 100755 script/package create mode 100755 script/run create mode 100755 script/setup create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 wyoming_satellite/__init__.py create mode 100644 wyoming_satellite/__main__.py create mode 100644 wyoming_satellite/example_event_client.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0c0a12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +.idea +*.log +tmp/ + +*.py[cod] +*.egg +*.egg-info/ +build +htmlcov + +/.venv/ +.mypy_cache/ +__pycache__/ + +/dist/ +/local/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..ba2778d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,6 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/.projectile b/.projectile new file mode 100644 index 0000000..796cfde --- /dev/null +++ b/.projectile @@ -0,0 +1 @@ +- /.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..216d1db --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## 1.0.0 + +- Initial release + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0875822 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Michael Hansen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f9bd145 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..200e3d6 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Wyoming Satellite + +Satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming). diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..261cf49 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +ignore_missing_imports = true + +[mypy-setuptools.*] +ignore_missing_imports = True diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..22a70d0 --- /dev/null +++ b/pylintrc @@ -0,0 +1,37 @@ +[MESSAGES CONTROL] +disable= + format, + abstract-method, + cyclic-import, + duplicate-code, + global-statement, + import-outside-toplevel, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unnecessary-pass, + unused-argument, + broad-except, + too-many-nested-blocks, + invalid-name, + unused-import, + fixme, + useless-super-delegation, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + import-error, + consider-using-with + +[FORMAT] +expected-line-ending-format=LF diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4b3bf95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +wyoming==1.3.0 +zeroconf==0.88.0 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..77190e6 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,5 @@ +black==22.12.0 +flake8==6.0.0 +isort==5.11.3 +mypy==0.991 +pylint==2.15.9 diff --git a/script/format b/script/format new file mode 100755 index 0000000..615bd8e --- /dev/null +++ b/script/format @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite" + +context = venv.EnvBuilder().ensure_directories(_VENV_DIR) +subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR)]) +subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR)]) diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..d535816 --- /dev/null +++ b/script/lint @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "wyoming_satellite" + +context = venv.EnvBuilder().ensure_directories(_VENV_DIR) +subprocess.check_call([context.env_exe, "-m", "black", str(_MODULE_DIR), "--check"]) +subprocess.check_call([context.env_exe, "-m", "isort", str(_MODULE_DIR), "--check"]) +subprocess.check_call([context.env_exe, "-m", "flake8", str(_MODULE_DIR)]) +subprocess.check_call([context.env_exe, "-m", "pylint", str(_MODULE_DIR)]) +subprocess.check_call([context.env_exe, "-m", "mypy", str(_MODULE_DIR)]) diff --git a/script/package b/script/package new file mode 100755 index 0000000..1f04c1f --- /dev/null +++ b/script/package @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +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, _PROGRAM_DIR / "setup.py", "bdist_wheel"]) diff --git a/script/run b/script/run new file mode 100755 index 0000000..a648150 --- /dev/null +++ b/script/run @@ -0,0 +1,12 @@ +#!/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, "-m", "wyoming_satellite"] + sys.argv[1:]) diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..92ec185 --- /dev/null +++ b/script/setup @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" + + +# Create virtual environment +builder = venv.EnvBuilder(with_pip=True) +context = builder.ensure_directories(_VENV_DIR) +builder.create(_VENV_DIR) + +# Upgrade dependencies +pip = [context.env_exe, "-m", "pip"] +subprocess.check_call(pip + ["install", "--upgrade", "pip"]) +subprocess.check_call(pip + ["install", "--upgrade", "setuptools", "wheel"]) + +# Install requirements +subprocess.check_call(pip + ["install", "-r", str(_PROGRAM_DIR / "requirements.txt")]) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0076bbf --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[flake8] +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d46657b --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from pathlib import Path + +import setuptools +from setuptools import setup + +this_dir = Path(__file__).parent +module_dir = this_dir / "wyoming_handle_external" + +requirements = [] +requirements_path = this_dir / "requirements.txt" +if requirements_path.is_file(): + with open(requirements_path, "r", encoding="utf-8") as requirements_file: + requirements = requirements_file.read().splitlines() + + +# ----------------------------------------------------------------------------- + +setup( + name="wyoming_handle_external", + version="1.0.0", + description="Wyoming server for external intent handling", + url="http://github.com/rhasspy/wyoming-handle-external", + author="Michael Hansen", + author_email="mike@rhasspy.org", + packages=setuptools.find_packages(), + install_requires=requirements, + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + keywords="rhasspy wyoming intent handle", +) diff --git a/wyoming_satellite/__init__.py b/wyoming_satellite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py new file mode 100644 index 0000000..425308c --- /dev/null +++ b/wyoming_satellite/__main__.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import logging +import time +from enum import Enum +from functools import partial +from pathlib import Path +from typing import Optional + +from wyoming.asr import Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.client import AsyncClient +from wyoming.event import Event +from wyoming.info import Attribution, Describe, Info, Satellite +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +_LOGGER = logging.getLogger() +_DIR = Path(__file__).parent + + +class SatelliteState(str, Enum): + WAKE = "wake" + ASR = "asr" + + +async def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser() + parser.add_argument( + "--mic-uri", required=True, help="URI of Wyoming microphone service" + ) + # + parser.add_argument("--snd-uri", help="URI of Wyoming sound service") + parser.add_argument("--wake-uri", help="URI of Wyoming wake word detection service") + parser.add_argument( + "--wake-word-name", + action="append", + default=[], + help="Name of wake word to listen for (requires --wake-uri)", + ) + # + parser.add_argument( + "--event-uri", help="URI of Wyoming service to forward events to" + ) + # + parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://") + parser.add_argument("--name", required=True, help="Name of the satellite") + parser.add_argument("--area", help="Area name of the satellite") + parser.add_argument("--pipeline", help="Name of server pipeline to run") + # + parser.add_argument( + "--no-zeroconf", action="store_true", help="Disable discovery over zeroconf" + ) + parser.add_argument( + "--zeroconf-name", + help="Name used for zeroconf discovery (default: satellite name)", + ) + # + parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + _LOGGER.debug(args) + + wyoming_info = Info( + satellite=Satellite( + name=args.name, + area=args.area, + description=args.name, + attribution=Attribution(name="", url=""), + installed=True, + ) + ) + + _LOGGER.info("Ready") + + # Start server + server = AsyncServer.from_uri(args.uri) + + if (not args.no_zeroconf) and isinstance(server, AsyncTcpServer): + from wyoming.zeroconf import register_server + + tcp_server: AsyncTcpServer = server + await register_server( + name=args.zeroconf_name or args.name, + port=tcp_server.port, + host=tcp_server.host, + ) + _LOGGER.debug("Zeroconf discovery enabled") + + try: + await server.run(partial(SatelliteEventHandler, wyoming_info, args)) + except KeyboardInterrupt: + pass + + +# ----------------------------------------------------------------------------- + + +class SatelliteEventHandler(AsyncEventHandler): + """Event handler for clients.""" + + def __init__( + self, + wyoming_info: Info, + 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.state: Optional[Satellite] = None + self.satellite_task: Optional[asyncio.Task] = None + self.has_snd = bool(self.cli_args.snd_uri) + self.snd_client: Optional[AsyncClient] = None + self.events_client: Optional[AsyncClient] = None + self.is_running = True + + _LOGGER.debug("Client connected: %s", self.client_id) + + async def handle_event(self, event: Event) -> bool: + if Describe.is_type(event.type): + await self.write_event(self.wyoming_info_event) + _LOGGER.debug("Sent info to client: %s", self.client_id) + return True + + if RunSatellite.is_type(event.type) and (self.satellite_task is None): + if self.cli_args.wake_uri: + # Local wake word detection + await self.write_event( + RunPipeline( + start_stage=PipelineStage.ASR, + end_stage=PipelineStage.TTS + if self.has_snd + else PipelineStage.HANDLE, + name=self.cli_args.pipeline, + ).event() + ) + self.state = SatelliteState.WAKE + self.satellite_task = asyncio.create_task(self.run_satellite_wake()) + else: + # Remote wake word detection + await self.write_event( + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS + if self.has_snd + else PipelineStage.HANDLE, + name=self.cli_args.pipeline, + ).event() + ) + self.state = None + self.satellite_task = asyncio.create_task(self.run_satellite()) + elif Transcript.is_type(event.type): + if self.state == SatelliteState.ASR: + # Ready for next wake word detection + self.state = SatelliteState.WAKE + _LOGGER.info("Detecting wake word") + + # STT transcript + await self.forward_event(event) + elif self.has_snd and ( + AudioStart.is_type(event.type) + or AudioChunk.is_type(event.type) + or AudioStop.is_type(event.type) + ): + if self.snd_client is None: + self.snd_client = AsyncClient.from_uri(self.cli_args.snd_uri) + await self.snd_client.connect() + + assert self.snd_client is not None + + try: + # Forward to sound service + await self.snd_client.write_event(event) + except Exception: + _LOGGER.exception("Unexpected error while sending snd audio") + + # Reconnect + self.snd_client = None + + if AudioStart.is_type(event.type) or AudioStop.is_type(event.type): + # TTS start/stop + await self.forward_event(event) + elif ( + Detect.is_type(event.type) + or Detection.is_type(event.type) + or VoiceStarted.is_type(event.type) + or VoiceStopped.is_type(event.type) + or Synthesize.is_type(event.type) + ): + # Other client events: + # - Detect for start of wake word detection + # - Detection for when wake word is detected + # - VoiceStarted/VoiceStopped for when user starts/stops speaking + # - Synthesize for TTS text + await self.forward_event(event) + else: + _LOGGER.debug("Unexpected event: type=%s, data=%s", event.type, event.data) + + return True + + async def run_satellite(self) -> None: + try: + _LOGGER.debug("Connecting to %s", self.cli_args.mic_uri) + async with AsyncClient.from_uri(self.cli_args.mic_uri) as mic_client: + _LOGGER.info("Streaming audio") + + while True: + mic_event = await mic_client.read_event() + if mic_event is None: + _LOGGER.warning("Microphone service disconnected") + break + + if AudioChunk.is_type(mic_event.type): + # Forward all audio to server + await self.write_event(mic_event) + + except Exception: + _LOGGER.exception("Unexpected error in run_satellite") + + async def run_satellite_wake(self) -> None: + try: + assert self.cli_args.wake_uri + + _LOGGER.debug("Connecting to %s", self.cli_args.mic_uri) + _LOGGER.debug("Connecting to %s", self.cli_args.wake_uri) + + async with AsyncClient.from_uri( + self.cli_args.mic_uri + ) as mic_client, AsyncClient.from_uri( + self.cli_args.wake_uri + ) as wake_client: + _LOGGER.info("Detecting wake word") + + if self.cli_args.wake_word_name: + # Request specific wake word(s) + detect_event = Detect(names=self.cli_args.wake_word_name).event() + else: + detect_event = Detect().event() + + await wake_client.write_event(detect_event) + await self.forward_event(detect_event) + + # Read events in parallel + mic_task = asyncio.create_task(mic_client.read_event()) + wake_task = asyncio.create_task(wake_client.read_event()) + pending = {mic_task, wake_task} + + while self.is_running: + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + + if mic_task in done: + mic_event = mic_task.result() + if mic_event is None: + _LOGGER.warning("Microphone service disconnected") + break + + if AudioChunk.is_type(mic_event.type): + # Forward to wake service + await wake_client.write_event(mic_event) + + if self.state == SatelliteState.ASR: + # Forward to server + await self.write_event(mic_event) + + # Next audio chunk + mic_task = asyncio.create_task(mic_client.read_event()) + pending.add(mic_task) + elif wake_task in done: + wake_event = wake_task.result() + if wake_event is None: + _LOGGER.warning("Wake word service disconnected") + break + + if Detection.is_type(wake_event.type) and ( + self.state == SatelliteState.WAKE + ): + _LOGGER.debug("Wake word detected: %s", wake_event) + self.state = SatelliteState.ASR + + # Forward to server + await self.write_event(wake_event) + + # Forward to client + await self.forward_event(wake_event) + + _LOGGER.info("Streaming audio") + + # Next wake event + wake_task = asyncio.create_task(wake_client.read_event()) + pending.add(wake_task) + except Exception: + _LOGGER.exception("Unexpected error in run_satellite") + + async def forward_event(self, event: Event) -> None: + """Forward a Wyoming event to a client.""" + if not self.cli_args.event_uri: + return + + if self.events_client is None: + self.events_client = AsyncClient.from_uri(self.cli_args.event_uri) + _LOGGER.debug("Connecting to %s", self.cli_args.event_uri) + await self.events_client.connect() + + assert self.events_client is not None + await self.events_client.write_event(event) + + async def disconnect(self) -> None: + self.is_running = False + + +# ----------------------------------------------------------------------------- + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/wyoming_satellite/example_event_client.py b/wyoming_satellite/example_event_client.py new file mode 100644 index 0000000..378b04c --- /dev/null +++ b/wyoming_satellite/example_event_client.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import logging +import time +from functools import partial +from pathlib import Path + +from wyoming.event import Event +from wyoming.server import AsyncEventHandler, AsyncServer + +_LOGGER = logging.getLogger() +_DIR = Path(__file__).parent + + +async def main() -> None: + """Main entry point.""" + parser = argparse.ArgumentParser() + parser.add_argument("--uri", default="stdio://", help="unix:// or tcp://") + # + parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") + args = parser.parse_args() + + logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO) + _LOGGER.debug(args) + + _LOGGER.info("Ready") + + # Start server + server = AsyncServer.from_uri(args.uri) + + try: + await server.run(partial(ExampleEventHandler, args)) + except KeyboardInterrupt: + pass + + +# ----------------------------------------------------------------------------- + + +class ExampleEventHandler(AsyncEventHandler): + """Event handler for clients.""" + + def __init__( + self, + cli_args: argparse.Namespace, + *args, + **kwargs, + ) -> None: + super().__init__(*args, **kwargs) + + self.cli_args = cli_args + self.client_id = str(time.monotonic_ns()) + + self.is_running = True + + _LOGGER.debug("Client connected: %s", self.client_id) + + async def handle_event(self, event: Event) -> bool: + _LOGGER.info(event) + + return True + + +# ----------------------------------------------------------------------------- + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass