Initial commit

This commit is contained in:
Michael Hansen
2023-11-28 17:01:04 -06:00
commit e01b6a21b9
21 changed files with 641 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
.DS_Store
.idea
*.log
tmp/
*.py[cod]
*.egg
*.egg-info/
build
htmlcov
/.venv/
.mypy_cache/
__pycache__/
/dist/
/local/
+6
View File
@@ -0,0 +1,6 @@
[settings]
multi_line_output=3
include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
+1
View File
@@ -0,0 +1 @@
- /.venv/
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## 1.0.0
- Initial release
+21
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
include requirements.txt
+3
View File
@@ -0,0 +1,3 @@
# Wyoming Satellite
Satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
+5
View File
@@ -0,0 +1,5 @@
[mypy]
ignore_missing_imports = true
[mypy-setuptools.*]
ignore_missing_imports = True
+37
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
wyoming==1.3.0
zeroconf==0.88.0
+5
View File
@@ -0,0 +1,5 @@
black==22.12.0
flake8==6.0.0
isort==5.11.3
mypy==0.991
pylint==2.15.9
Executable
+13
View File
@@ -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)])
Executable
+16
View File
@@ -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)])
Executable
+11
View File
@@ -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"])
Executable
+12
View File
@@ -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:])
Executable
+22
View File
@@ -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")])
+22
View File
@@ -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 = " "
+39
View File
@@ -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",
)
View File
+331
View File
@@ -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
+71
View File
@@ -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