Initial commit
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
*.py[cod]
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
build
|
||||||
|
htmlcov
|
||||||
|
|
||||||
|
/.venv/
|
||||||
|
.mypy_cache/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
/dist/
|
||||||
|
/local/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[settings]
|
||||||
|
multi_line_output=3
|
||||||
|
include_trailing_comma=True
|
||||||
|
force_grid_wrap=0
|
||||||
|
use_parentheses=True
|
||||||
|
line_length=88
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- /.venv/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial release
|
||||||
|
|
||||||
+21
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
include requirements.txt
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Wyoming Satellite
|
||||||
|
|
||||||
|
Satellite using the [Wyoming protocol](https://github.com/rhasspy/wyoming).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[mypy]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[mypy-setuptools.*]
|
||||||
|
ignore_missing_imports = True
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
wyoming==1.3.0
|
||||||
|
zeroconf==0.88.0
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")])
|
||||||
@@ -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 = " "
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user