commit e01b6a21b99143f75e96c332a4c56f5b5398cd9d Author: Michael Hansen Date: Tue Nov 28 17:01:04 2023 -0600 Initial commit 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