Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962b2f5dc6 | ||
|
|
36353ff03a | ||
|
|
70652bbea2 | ||
|
|
f0dcf39946 | ||
|
|
42325ec284 | ||
|
|
1bc3471637 | ||
|
|
8850100ca6 | ||
|
|
8e793a185d | ||
|
|
12cce8e8f5 | ||
|
|
fbc129711d | ||
|
|
7a45d6b2e9 | ||
|
|
9e6195baa7 | ||
|
|
9fda18770b | ||
|
|
503dc952bb | ||
|
|
366547c457 | ||
|
|
74f22e649c | ||
|
|
865b40722b | ||
|
|
20bbd3cb4f | ||
|
|
038e9667d2 | ||
|
|
8b24216e4e | ||
|
|
87bfcf4bf7 | ||
|
|
090b2588b7 | ||
|
|
a638600348 | ||
|
|
d1578cb863 | ||
|
|
086f346119 | ||
|
|
3b2842d11b | ||
|
|
1bceb97177 | ||
|
|
71ab73ad73 | ||
|
|
160370be5a | ||
|
|
594f6f696d | ||
|
|
bd9de77a4c | ||
|
|
5088b590a6 | ||
|
|
79dee35659 |
@@ -5,5 +5,7 @@
|
||||
!script/setup
|
||||
!script/run
|
||||
!wyoming_satellite/*.py
|
||||
!wyoming_satellite/utils
|
||||
!wyoming_satellite/VERSION
|
||||
!docker/run
|
||||
!sounds/*.wav
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
name: test
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- Add `--tts-played-command`
|
||||
- Add `--mic-seconds-to-mute-after-awake-wav` and `--mic-no-mute-during-awake-wav`
|
||||
- Send preferred sound format to server
|
||||
|
||||
## 1.1.1
|
||||
|
||||
- Bump to wyoming 1.5.2 (package fix)
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- Bump to wyoming 1.5.1
|
||||
- Send wyoming-satellite version in `info` message
|
||||
- Ping server if supported (faster awareness of disconnection)
|
||||
- Support `pause-satellite` message
|
||||
- Stop streaming/wake word detection as soon as `pause-satellite` is received or server disconnects
|
||||
- Mute microphone when awake WAV is playing
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Initial release
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
include requirements.txt
|
||||
include VERSION
|
||||
|
||||
@@ -173,8 +173,9 @@ Satellites can respond to events from the server by running commands:
|
||||
* `--stt-start-command` - user started speaking (no stdin)
|
||||
* `--stt-stop-command` - user stopped speaking (no stdin)
|
||||
* `--synthesize-command` - text-to-speech text is returned (text on stdin)
|
||||
* `--tts-start-command` - text-to-speech response started (no stdin)
|
||||
* `--tts-stop-command` - text-to-speech response stopped (no stdin)
|
||||
* `--tts-start-command` - text-to-speech response started streaming from server (no stdin)
|
||||
* `--tts-stop-command` - text-to-speech response stopped streaming from server. Can still being played by snd service (no stdin)
|
||||
* `--tts-played-command` - text-to-speech audio finished playing (no stdin)
|
||||
* `--error-command` - an error was sent from the server (text on stdin)
|
||||
* `--connected-command` - satellite connected to server
|
||||
* `--disconnected-command` - satellite disconnected from server
|
||||
|
||||
@@ -302,7 +302,7 @@ cd wyoming-satellite/examples
|
||||
python3 -m venv --system-site-packages .venv
|
||||
.venv/bin/pip3 install --upgrade pip
|
||||
.venv/bin/pip3 install --upgrade wheel setuptools
|
||||
.venv/bin/pip3 install 'wyoming==1.4.1'
|
||||
.venv/bin/pip3 install 'wyoming==1.5.2'
|
||||
```
|
||||
|
||||
The `--system-site-packages` argument is used to access the pre-installed `gpiozero` and `spidev` Python packages. If these are **not already installed** in your system, run:
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
wyoming==1.5.0
|
||||
wyoming==1.5.3
|
||||
zeroconf==0.88.0
|
||||
pyring-buffer==1.0.0
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
docker buildx build . \
|
||||
docker build . \
|
||||
-t 'rhasspy/wyoming-satellite' "$@"
|
||||
|
||||
@@ -6,6 +6,9 @@ import setuptools
|
||||
from setuptools import setup
|
||||
|
||||
this_dir = Path(__file__).parent
|
||||
module_dir = this_dir / "wyoming_satellite"
|
||||
version_path = module_dir / "VERSION"
|
||||
version = version_path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def get_requirements(req_path: Path) -> List[str]:
|
||||
@@ -41,13 +44,14 @@ setup(
|
||||
author="Michael Hansen",
|
||||
author_email="mike@rhasspy.org",
|
||||
packages=setuptools.find_packages(),
|
||||
package_data={
|
||||
"wyoming_satellite": [str(p.relative_to(module_dir)) for p in (version_path,)]
|
||||
},
|
||||
install_requires=install_requires,
|
||||
classifiers=[
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT 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",
|
||||
|
||||
+59
-1
@@ -1,6 +1,64 @@
|
||||
"""Shared code for Wyoming satellite tests."""
|
||||
from wyoming.audio import AudioChunk
|
||||
import asyncio
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.client import AsyncClient
|
||||
from wyoming.event import Event
|
||||
|
||||
AUDIO_START = AudioStart(rate=16000, width=2, channels=1)
|
||||
AUDIO_STOP = AudioStop()
|
||||
|
||||
AUDIO_CHUNK = AudioChunk(
|
||||
rate=16000, width=2, channels=1, audio=bytes([255] * 960) # 30ms
|
||||
)
|
||||
|
||||
|
||||
class FakeStreamReaderWriter:
|
||||
def __init__(self) -> None:
|
||||
self._undrained_data = bytes()
|
||||
self._value = bytes()
|
||||
self._data_ready = asyncio.Event()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._undrained_data += data
|
||||
|
||||
def writelines(self, data: Iterable[bytes]) -> None:
|
||||
for line in data:
|
||||
self.write(line)
|
||||
|
||||
async def drain(self) -> None:
|
||||
self._value += self._undrained_data
|
||||
self._undrained_data = bytes()
|
||||
self._data_ready.set()
|
||||
self._data_ready.clear()
|
||||
|
||||
async def readline(self) -> bytes:
|
||||
while b"\n" not in self._value:
|
||||
await self._data_ready.wait()
|
||||
|
||||
with io.BytesIO(self._value) as value_io:
|
||||
data = value_io.readline()
|
||||
self._value = self._value[len(data) :]
|
||||
return data
|
||||
|
||||
async def readexactly(self, n: int) -> bytes:
|
||||
while len(self._value) < n:
|
||||
await self._data_ready.wait()
|
||||
|
||||
data = self._value[:n]
|
||||
self._value = self._value[n:]
|
||||
return data
|
||||
|
||||
|
||||
class MicClient(AsyncClient):
|
||||
async def read_event(self) -> Optional[Event]:
|
||||
# Send 30ms of audio every 30ms
|
||||
await asyncio.sleep(AUDIO_CHUNK.seconds)
|
||||
return AUDIO_CHUNK.event()
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
# Output only
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Final, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.client import AsyncClient
|
||||
from wyoming.event import Event, async_read_event
|
||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||
from wyoming.satellite import RunSatellite, StreamingStarted, StreamingStopped
|
||||
from wyoming.tts import Synthesize
|
||||
from wyoming.wake import Detection
|
||||
|
||||
from wyoming_satellite import (
|
||||
EventSettings,
|
||||
MicSettings,
|
||||
SatelliteSettings,
|
||||
SndSettings,
|
||||
WakeSettings,
|
||||
WakeStreamingSatellite,
|
||||
)
|
||||
|
||||
from .shared import (
|
||||
AUDIO_CHUNK,
|
||||
AUDIO_START,
|
||||
AUDIO_STOP,
|
||||
FakeStreamReaderWriter,
|
||||
MicClient,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
TIMEOUT: Final = 1
|
||||
|
||||
|
||||
class WakeClient(AsyncClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._event_ready = asyncio.Event()
|
||||
self._event: Optional[Event] = None
|
||||
self._detected: bool = False
|
||||
|
||||
async def read_event(self) -> Optional[Event]:
|
||||
await self._event_ready.wait()
|
||||
self._event_ready.clear()
|
||||
return self._event
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
if AudioChunk.is_type(event.type):
|
||||
if not self._detected:
|
||||
self._detected = True
|
||||
self._event = Detection().event()
|
||||
self._event_ready.set()
|
||||
|
||||
|
||||
class SndClient(AsyncClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.synthesize = asyncio.Event()
|
||||
self.audio_start = asyncio.Event()
|
||||
self.audio_chunk = asyncio.Event()
|
||||
self.audio_stop = asyncio.Event()
|
||||
|
||||
async def read_event(self) -> Optional[Event]:
|
||||
# Input only
|
||||
pass
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
if AudioChunk.is_type(event.type):
|
||||
self.audio_chunk.set()
|
||||
elif Synthesize.is_type(event.type):
|
||||
self.synthesize.set()
|
||||
elif AudioStart.is_type(event.type):
|
||||
self.audio_start.set()
|
||||
elif AudioStop.is_type(event.type):
|
||||
self.audio_stop.set()
|
||||
|
||||
|
||||
class EventClient(AsyncClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.detection = asyncio.Event()
|
||||
self.streaming_started = asyncio.Event()
|
||||
self.streaming_stopped = asyncio.Event()
|
||||
self.transcript = asyncio.Event()
|
||||
self.synthesize = asyncio.Event()
|
||||
self.audio_start = asyncio.Event()
|
||||
self.audio_chunk = asyncio.Event()
|
||||
self.audio_stop = asyncio.Event()
|
||||
|
||||
async def read_event(self) -> Optional[Event]:
|
||||
# Input only
|
||||
return None
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
if Detection.is_type(event.type):
|
||||
self.detection.set()
|
||||
elif StreamingStarted.is_type(event.type):
|
||||
self.streaming_started.set()
|
||||
elif StreamingStopped.is_type(event.type):
|
||||
self.streaming_stopped.set()
|
||||
elif Transcript.is_type(event.type):
|
||||
self.transcript.set()
|
||||
elif Synthesize.is_type(event.type):
|
||||
self.synthesize.set()
|
||||
elif AudioChunk.is_type(event.type):
|
||||
self.audio_chunk.set()
|
||||
elif AudioStart.is_type(event.type):
|
||||
self.audio_start.set()
|
||||
elif AudioStop.is_type(event.type):
|
||||
self.audio_stop.set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wake_satellite() -> None:
|
||||
mic_client = MicClient()
|
||||
snd_client = SndClient()
|
||||
wake_client = WakeClient()
|
||||
event_client = EventClient()
|
||||
|
||||
with patch(
|
||||
"wyoming_satellite.satellite.SatelliteBase._make_mic_client",
|
||||
return_value=mic_client,
|
||||
), patch(
|
||||
"wyoming_satellite.satellite.SatelliteBase._make_snd_client",
|
||||
return_value=snd_client,
|
||||
), patch(
|
||||
"wyoming_satellite.satellite.SatelliteBase._make_wake_client",
|
||||
return_value=wake_client,
|
||||
), patch(
|
||||
"wyoming_satellite.satellite.SatelliteBase._make_event_client",
|
||||
return_value=event_client,
|
||||
):
|
||||
satellite = WakeStreamingSatellite(
|
||||
SatelliteSettings(
|
||||
mic=MicSettings(uri="test"),
|
||||
snd=SndSettings(uri="test"),
|
||||
wake=WakeSettings(uri="test"),
|
||||
event=EventSettings(uri="test"),
|
||||
)
|
||||
)
|
||||
|
||||
async def event_from_satellite() -> Optional[Event]:
|
||||
return await async_read_event(server_io)
|
||||
|
||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
||||
|
||||
# Fake server connection
|
||||
server_io = FakeStreamReaderWriter()
|
||||
await satellite.set_server("test", server_io) # type: ignore
|
||||
|
||||
# Start satellite
|
||||
await satellite.event_from_server(RunSatellite().event())
|
||||
|
||||
# Trigger detection
|
||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||
assert event is not None
|
||||
assert Detection.is_type(event.type), event
|
||||
|
||||
# Pipeline should start
|
||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||
assert event is not None
|
||||
assert RunPipeline.is_type(event.type), event
|
||||
run_pipeline = RunPipeline.from_event(event)
|
||||
assert run_pipeline.start_stage == PipelineStage.ASR
|
||||
assert run_pipeline.end_stage == PipelineStage.TTS
|
||||
|
||||
# Event service should have received detection
|
||||
await asyncio.wait_for(event_client.detection.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Server should be receiving audio now
|
||||
assert satellite.is_streaming, "Not streaming"
|
||||
for _ in range(5):
|
||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||
assert event is not None
|
||||
assert AudioChunk.is_type(event.type)
|
||||
|
||||
# Event service should have received streaming start
|
||||
await asyncio.wait_for(event_client.streaming_started.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Send transcript
|
||||
await satellite.event_from_server(Transcript(text="test").event())
|
||||
|
||||
# Event service should have received transcript
|
||||
await asyncio.wait_for(event_client.transcript.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Wait for streaming to stop
|
||||
while satellite.is_streaming:
|
||||
event = await asyncio.wait_for(event_from_satellite(), timeout=TIMEOUT)
|
||||
assert event is not None
|
||||
assert AudioChunk.is_type(event.type)
|
||||
|
||||
# Event service should have received streaming stop
|
||||
await asyncio.wait_for(event_client.streaming_stopped.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Fake a TTS response
|
||||
await satellite.event_from_server(Synthesize(text="test").event())
|
||||
|
||||
# Event service should have received synthesize
|
||||
await asyncio.wait_for(event_client.synthesize.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Audio start, chunk, stop
|
||||
await satellite.event_from_server(AUDIO_START.event())
|
||||
await asyncio.wait_for(snd_client.audio_start.wait(), timeout=TIMEOUT)
|
||||
await asyncio.wait_for(event_client.audio_start.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Event service does not get audio chunks, just start/stop
|
||||
await satellite.event_from_server(AUDIO_CHUNK.event())
|
||||
await asyncio.wait_for(snd_client.audio_chunk.wait(), timeout=TIMEOUT)
|
||||
|
||||
await satellite.event_from_server(AUDIO_STOP.event())
|
||||
await asyncio.wait_for(snd_client.audio_stop.wait(), timeout=TIMEOUT)
|
||||
await asyncio.wait_for(event_client.audio_stop.wait(), timeout=TIMEOUT)
|
||||
|
||||
# Stop satellite
|
||||
await satellite.stop()
|
||||
await satellite_task
|
||||
@@ -20,22 +20,11 @@ from wyoming_satellite import (
|
||||
WakeStreamingSatellite,
|
||||
)
|
||||
|
||||
from .shared import AUDIO_CHUNK
|
||||
from .shared import MicClient
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
|
||||
|
||||
class MicClient(AsyncClient):
|
||||
async def read_event(self) -> Optional[Event]:
|
||||
# Send 30ms of audio every 30ms
|
||||
await asyncio.sleep(AUDIO_CHUNK.seconds)
|
||||
return AUDIO_CHUNK.event()
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
# Output only
|
||||
pass
|
||||
|
||||
|
||||
class WakeClient(AsyncClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -60,7 +49,6 @@ class EventClient(AsyncClient):
|
||||
return None
|
||||
|
||||
async def write_event(self, event: Event) -> None:
|
||||
_LOGGER.error(event.type)
|
||||
if Detection.is_type(event.type):
|
||||
self.wake_event.set()
|
||||
|
||||
@@ -89,6 +77,9 @@ async def test_multiple_wakeups(tmp_path: Path) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# Fake server connection
|
||||
satellite.server_id = "test"
|
||||
|
||||
satellite_task = asyncio.create_task(satellite.run(), name="satellite")
|
||||
await satellite.event_from_server(RunSatellite().event())
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[tox]
|
||||
env_list =
|
||||
py{39,310,311}
|
||||
minversion = 4.12.1
|
||||
|
||||
[testenv]
|
||||
description = run the tests with pytest
|
||||
package = wheel
|
||||
wheel_build_env = .pkg
|
||||
deps =
|
||||
pytest>=7,<8
|
||||
pytest-asyncio<1
|
||||
commands =
|
||||
pytest {tty:--color=yes} {posargs}
|
||||
@@ -0,0 +1 @@
|
||||
1.2.0
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Voice satellite using the Wyoming protocol."""
|
||||
from pathlib import Path
|
||||
|
||||
from .satellite import (
|
||||
AlwaysStreamingSatellite,
|
||||
SatelliteBase,
|
||||
@@ -14,7 +16,11 @@ from .settings import (
|
||||
WakeSettings,
|
||||
)
|
||||
|
||||
_DIR = Path(__file__).parent
|
||||
__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip()
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AlwaysStreamingSatellite",
|
||||
"EventSettings",
|
||||
"MicSettings",
|
||||
@@ -23,6 +29,6 @@ __all__ = [
|
||||
"SndSettings",
|
||||
"VadSettings",
|
||||
"VadStreamingSatellite",
|
||||
"WakeSettings",
|
||||
"WakeStreamingSatellite",
|
||||
"WakeSettings",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
||||
from wyoming.info import Attribution, Info, Satellite
|
||||
from wyoming.server import AsyncServer, AsyncTcpServer
|
||||
|
||||
from . import __version__
|
||||
from .event_handler import SatelliteEventHandler
|
||||
from .satellite import (
|
||||
AlwaysStreamingSatellite,
|
||||
@@ -23,6 +24,7 @@ from .settings import (
|
||||
SndSettings,
|
||||
VadSettings,
|
||||
WakeSettings,
|
||||
WakeWordAndPipeline,
|
||||
)
|
||||
from .utils import (
|
||||
get_mac_address,
|
||||
@@ -72,6 +74,22 @@ async def main() -> None:
|
||||
"--mic-noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4)
|
||||
)
|
||||
parser.add_argument("--mic-auto-gain", type=int, default=0, choices=list(range(32)))
|
||||
parser.add_argument(
|
||||
"--mic-seconds-to-mute-after-awake-wav",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to mute microphone after awake wav is finished playing (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-no-mute-during-awake-wav",
|
||||
action="store_true",
|
||||
help="Don't mute the microphone while awake wav is being played",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mic-channel-index",
|
||||
type=int,
|
||||
help="Take microphone input from a specific channel (first channel is 0)",
|
||||
)
|
||||
|
||||
# Sound output
|
||||
parser.add_argument("--snd-uri", help="URI of Wyoming sound service")
|
||||
@@ -102,7 +120,9 @@ async def main() -> None:
|
||||
"--wake-word-name",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Name of wake word to listen for (requires --wake-uri)",
|
||||
nargs="+",
|
||||
metavar=("name", "pipeline"),
|
||||
help="Name of wake word to listen for and optional pipeline name to run (requires --wake-uri)",
|
||||
)
|
||||
parser.add_argument("--wake-command", help="Program to run for wake word detection")
|
||||
parser.add_argument(
|
||||
@@ -181,6 +201,10 @@ async def main() -> None:
|
||||
"--tts-stop-command",
|
||||
help="Command to run when text to speech response stops",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tts-played-command",
|
||||
help="Command to run when text-to-speech audio stopped playing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-start-command",
|
||||
help="Command to run when audio streaming starts",
|
||||
@@ -234,6 +258,15 @@ async def main() -> None:
|
||||
"--debug-recording-dir", help="Directory to store audio for debugging"
|
||||
)
|
||||
parser.add_argument("--debug", action="store_true", help="Log DEBUG messages")
|
||||
parser.add_argument(
|
||||
"--log-format", default=logging.BASIC_FORMAT, help="Format for log messages"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=__version__,
|
||||
help="Print version and exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate args
|
||||
@@ -266,7 +299,9 @@ async def main() -> None:
|
||||
if args.vad and (args.wake_uri or args.wake_command):
|
||||
_LOGGER.warning("VAD is not used with local wake word detection")
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.debug else logging.INFO, format=args.log_format
|
||||
)
|
||||
_LOGGER.debug(args)
|
||||
|
||||
if args.debug_recording_dir:
|
||||
@@ -280,6 +315,7 @@ async def main() -> None:
|
||||
description=args.name,
|
||||
attribution=Attribution(name="", url=""),
|
||||
installed=True,
|
||||
version=__version__,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -294,6 +330,9 @@ async def main() -> None:
|
||||
volume_multiplier=args.mic_volume_multiplier,
|
||||
auto_gain=args.mic_auto_gain,
|
||||
noise_suppression=args.mic_noise_suppression,
|
||||
seconds_to_mute_after_awake_wav=args.mic_seconds_to_mute_after_awake_wav,
|
||||
mute_during_awake_wav=(not args.mic_no_mute_during_awake_wav),
|
||||
channel_index=args.mic_channel_index,
|
||||
),
|
||||
vad=VadSettings(
|
||||
enabled=args.vad,
|
||||
@@ -305,7 +344,9 @@ async def main() -> None:
|
||||
wake=WakeSettings(
|
||||
uri=args.wake_uri,
|
||||
command=split_command(args.wake_command),
|
||||
names=args.wake_word_name,
|
||||
names=[
|
||||
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
|
||||
],
|
||||
refractory_seconds=args.wake_refractory_seconds
|
||||
if args.wake_refractory_seconds > 0
|
||||
else None,
|
||||
@@ -327,6 +368,7 @@ async def main() -> None:
|
||||
streaming_stop=split_command(args.streaming_stop_command),
|
||||
detect=split_command(args.detect_command),
|
||||
detection=split_command(args.detection_command),
|
||||
played=split_command(args.tts_played_command),
|
||||
transcript=split_command(args.transcript_command),
|
||||
stt_start=split_command(args.stt_start_command),
|
||||
stt_stop=split_command(args.stt_stop_command),
|
||||
|
||||
@@ -26,7 +26,7 @@ class SatelliteEventHandler(AsyncEventHandler):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cli_args = cli_args
|
||||
self.wyoming_info_event = wyoming_info.event()
|
||||
self.wyoming_info = wyoming_info
|
||||
self.client_id = str(time.monotonic_ns())
|
||||
self.satellite = satellite
|
||||
|
||||
@@ -35,7 +35,8 @@ class SatelliteEventHandler(AsyncEventHandler):
|
||||
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)
|
||||
await self.satellite.update_info(self.wyoming_info)
|
||||
await self.write_event(self.wyoming_info.event())
|
||||
return True
|
||||
|
||||
if self.satellite.server_id is None:
|
||||
|
||||
+181
-29
@@ -1,4 +1,5 @@
|
||||
"""Satellite code."""
|
||||
import array
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
@@ -6,31 +7,39 @@ import time
|
||||
import wave
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Final, Optional, Set, Union
|
||||
from typing import Callable, Dict, Final, List, Optional, Set, Union
|
||||
|
||||
from pyring_buffer import RingBuffer
|
||||
from wyoming.asr import Transcript
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.audio import AudioChunk, AudioFormat, AudioStart, AudioStop
|
||||
from wyoming.client import AsyncClient
|
||||
from wyoming.error import Error
|
||||
from wyoming.event import Event, async_write_event
|
||||
from wyoming.info import Describe, Info
|
||||
from wyoming.mic import MicProcessAsyncClient
|
||||
from wyoming.ping import Ping, Pong
|
||||
from wyoming.pipeline import PipelineStage, RunPipeline
|
||||
from wyoming.satellite import (
|
||||
PauseSatellite,
|
||||
RunSatellite,
|
||||
SatelliteConnected,
|
||||
SatelliteDisconnected,
|
||||
StreamingStarted,
|
||||
StreamingStopped,
|
||||
)
|
||||
from wyoming.snd import SndProcessAsyncClient
|
||||
from wyoming.snd import Played, SndProcessAsyncClient
|
||||
from wyoming.tts import Synthesize
|
||||
from wyoming.vad import VoiceStarted, VoiceStopped
|
||||
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
|
||||
|
||||
from .settings import SatelliteSettings
|
||||
from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events
|
||||
from .utils import (
|
||||
DebugAudioWriter,
|
||||
multiply_volume,
|
||||
normalize_wake_word,
|
||||
run_event_command,
|
||||
wav_to_events,
|
||||
)
|
||||
from .vad import SileroVad
|
||||
from .webrtc import WebRtcAudio
|
||||
|
||||
@@ -38,6 +47,7 @@ _LOGGER = logging.getLogger()
|
||||
|
||||
_PONG_TIMEOUT: Final = 5
|
||||
_PING_SEND_DELAY: Final = 2
|
||||
_WAKE_INFO_TIMEOUT: Final = 2
|
||||
|
||||
|
||||
class State(Enum):
|
||||
@@ -73,7 +83,7 @@ class SatelliteBase:
|
||||
|
||||
self._ping_server_enabled: bool = False
|
||||
self._pong_received_event = asyncio.Event()
|
||||
self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping")
|
||||
self._ping_server_task: Optional[asyncio.Task] = None
|
||||
|
||||
self.microphone_muted = False
|
||||
self._unmute_microphone_task: Optional[asyncio.Task] = None
|
||||
@@ -149,6 +159,8 @@ class SatelliteBase:
|
||||
"""Remove writer."""
|
||||
self.server_id = None
|
||||
self._writer = None
|
||||
self._disable_ping()
|
||||
|
||||
_LOGGER.debug("Server disconnected")
|
||||
await self.trigger_server_disonnected()
|
||||
|
||||
@@ -167,6 +179,16 @@ class SatelliteBase:
|
||||
else:
|
||||
_LOGGER.exception("Unexpected error sending event to server")
|
||||
|
||||
def _enable_ping(self) -> None:
|
||||
self._ping_server_enabled = True
|
||||
self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping")
|
||||
|
||||
def _disable_ping(self) -> None:
|
||||
self._ping_server_enabled = False
|
||||
if self._ping_server_task is not None:
|
||||
self._ping_server_task.cancel()
|
||||
self._ping_server_task = None
|
||||
|
||||
async def _ping_server(self) -> None:
|
||||
try:
|
||||
while self.is_running:
|
||||
@@ -189,6 +211,8 @@ class SatelliteBase:
|
||||
|
||||
_LOGGER.warning("Did not receive ping response within timeout")
|
||||
await self.clear_server()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in ping server task")
|
||||
|
||||
@@ -210,6 +234,7 @@ class SatelliteBase:
|
||||
self._writer = None
|
||||
|
||||
await self._disconnect_from_services()
|
||||
self._disable_ping()
|
||||
self.state = State.STOPPED
|
||||
|
||||
async def stopped(self) -> None:
|
||||
@@ -222,8 +247,10 @@ class SatelliteBase:
|
||||
ping = Ping.from_event(event)
|
||||
await self.event_to_server(Pong(text=ping.text).event())
|
||||
|
||||
# Enable pinging
|
||||
self._ping_server_enabled = True
|
||||
if not self._ping_server_enabled:
|
||||
# Enable pinging
|
||||
self._enable_ping()
|
||||
_LOGGER.debug("Ping enabled")
|
||||
elif Pong.is_type(event.type):
|
||||
# Response from our ping
|
||||
self._pong_received_event.set()
|
||||
@@ -267,7 +294,7 @@ class SatelliteBase:
|
||||
if not AudioChunk.is_type(event.type):
|
||||
await self.forward_event(event)
|
||||
|
||||
async def _send_run_pipeline(self) -> None:
|
||||
async def _send_run_pipeline(self, pipeline_name: Optional[str] = None) -> None:
|
||||
"""Sends a RunPipeline event with the correct stages."""
|
||||
if self.settings.wake.enabled:
|
||||
# Local wake word detection
|
||||
@@ -286,8 +313,17 @@ class SatelliteBase:
|
||||
end_stage = PipelineStage.HANDLE
|
||||
|
||||
run_pipeline = RunPipeline(
|
||||
start_stage=start_stage, end_stage=end_stage, restart_on_end=restart_on_end
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
name=pipeline_name,
|
||||
restart_on_end=restart_on_end,
|
||||
snd_format=AudioFormat(
|
||||
rate=self.settings.snd.rate,
|
||||
width=self.settings.snd.width,
|
||||
channels=self.settings.snd.channels,
|
||||
),
|
||||
).event()
|
||||
_LOGGER.debug(run_pipeline)
|
||||
await self.event_to_server(run_pipeline)
|
||||
await self.forward_event(run_pipeline)
|
||||
|
||||
@@ -425,15 +461,36 @@ class SatelliteBase:
|
||||
continue
|
||||
|
||||
# Audio processing
|
||||
if self.settings.mic.needs_processing and AudioChunk.is_type(
|
||||
event.type
|
||||
if (
|
||||
self.settings.mic.needs_processing
|
||||
or (self.settings.mic.channel_index is not None)
|
||||
and AudioChunk.is_type(event.type)
|
||||
):
|
||||
chunk = AudioChunk.from_event(event)
|
||||
audio_bytes = self._process_mic_audio(chunk.audio)
|
||||
if self.settings.mic.channel_index is not None:
|
||||
if chunk.width != 2:
|
||||
raise ValueError(
|
||||
"Mic channel index selection requires 16-bit samples"
|
||||
)
|
||||
|
||||
# Convert to unsigned 16-bit array to make channel extraction easier
|
||||
audio_array = array.array("H", chunk.audio)
|
||||
audio_bytes = audio_array[
|
||||
self.settings.mic.channel_index :: chunk.channels
|
||||
].tobytes()
|
||||
|
||||
if self.settings.mic.needs_processing:
|
||||
if audio_bytes is None:
|
||||
audio_bytes = chunk.audio
|
||||
|
||||
audio_bytes = self._process_mic_audio(chunk.audio)
|
||||
|
||||
event = AudioChunk(
|
||||
rate=chunk.rate,
|
||||
width=chunk.width,
|
||||
channels=chunk.channels,
|
||||
channels=chunk.channels
|
||||
if (self.settings.mic.channel_index is None)
|
||||
else 1,
|
||||
audio=audio_bytes,
|
||||
).event()
|
||||
else:
|
||||
@@ -532,6 +589,7 @@ class SatelliteBase:
|
||||
event.type
|
||||
):
|
||||
await _disconnect()
|
||||
await self.trigger_played()
|
||||
snd_client = None # reconnect on next event
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
@@ -715,7 +773,11 @@ class SatelliteBase:
|
||||
|
||||
async def _send_wake_detect(self) -> None:
|
||||
"""Inform wake word service of which wake words to detect."""
|
||||
await self.event_to_wake(Detect(names=self.settings.wake.names).event())
|
||||
wake_names: Optional[List[str]] = None
|
||||
if self.settings.wake.names:
|
||||
wake_names = [w.name for w in self.settings.wake.names]
|
||||
|
||||
await self.event_to_wake(Detect(names=wake_names).event())
|
||||
await self.trigger_detect()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -756,6 +818,11 @@ class SatelliteBase:
|
||||
mute_microphone=self.settings.mic.mute_during_awake_wav,
|
||||
)
|
||||
|
||||
async def trigger_played(self) -> None:
|
||||
"""Called when audio stopped playing"""
|
||||
await run_event_command(self.settings.event.played)
|
||||
await self.forward_event(Played().event())
|
||||
|
||||
async def trigger_transcript(self, transcript: Transcript) -> None:
|
||||
"""Called when speech-to-text text is received."""
|
||||
await run_event_command(self.settings.event.transcript, transcript.text)
|
||||
@@ -833,6 +900,13 @@ class SatelliteBase:
|
||||
|
||||
await _disconnect()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Info
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def update_info(self, info: Info) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -858,6 +932,9 @@ class AlwaysStreamingSatellite(SatelliteBase):
|
||||
_LOGGER.info("Streaming audio")
|
||||
await self._send_run_pipeline()
|
||||
await self.trigger_streaming_start()
|
||||
elif PauseSatellite.is_type(event.type):
|
||||
self.is_streaming = False
|
||||
_LOGGER.info("Satellite paused")
|
||||
elif Detection.is_type(event.type):
|
||||
# Start debug recording
|
||||
if self.stt_audio_writer is not None:
|
||||
@@ -925,16 +1002,27 @@ class VadStreamingSatellite(SatelliteBase):
|
||||
if settings.wake.enabled:
|
||||
_LOGGER.warning("Local wake word detection is enabled but will not be used")
|
||||
|
||||
self._is_paused = False
|
||||
|
||||
async def event_from_server(self, event: Event) -> None:
|
||||
await super().event_from_server(event)
|
||||
|
||||
if RunSatellite.is_type(event.type):
|
||||
self._is_paused = False
|
||||
_LOGGER.info("Waiting for speech")
|
||||
elif Detection.is_type(event.type):
|
||||
# Start debug recording
|
||||
if self.stt_audio_writer is not None:
|
||||
self.stt_audio_writer.start()
|
||||
elif Transcript.is_type(event.type) or Error.is_type(event.type):
|
||||
elif (
|
||||
Transcript.is_type(event.type)
|
||||
or Error.is_type(event.type)
|
||||
or PauseSatellite.is_type(event.type)
|
||||
):
|
||||
if PauseSatellite.is_type(event.type):
|
||||
self._is_paused = True
|
||||
_LOGGER.debug("Satellite paused")
|
||||
|
||||
self.is_streaming = False
|
||||
|
||||
# Stop debug recording
|
||||
@@ -944,7 +1032,11 @@ class VadStreamingSatellite(SatelliteBase):
|
||||
async def event_from_mic(
|
||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||
) -> None:
|
||||
if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
|
||||
if (
|
||||
(not AudioChunk.is_type(event.type))
|
||||
or self.microphone_muted
|
||||
or self._is_paused
|
||||
):
|
||||
return
|
||||
|
||||
# Only unpack chunk once
|
||||
@@ -1065,20 +1157,30 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
# same timestamp.
|
||||
self._debug_recording_timestamp: Optional[int] = None
|
||||
|
||||
self._is_paused = False
|
||||
|
||||
self._wake_info: Optional[Info] = None
|
||||
self._wake_info_ready = asyncio.Event()
|
||||
|
||||
async def event_from_server(self, event: Event) -> None:
|
||||
# Only check event types once
|
||||
is_run_satellite = False
|
||||
is_pause_satellite = False
|
||||
is_transcript = False
|
||||
is_error = False
|
||||
|
||||
if RunSatellite.is_type(event.type):
|
||||
is_run_satellite = True
|
||||
self._is_paused = False
|
||||
|
||||
elif PauseSatellite.is_type(event.type):
|
||||
is_pause_satellite = True
|
||||
elif Transcript.is_type(event.type):
|
||||
is_transcript = True
|
||||
elif Error.is_type(event.type):
|
||||
is_error = True
|
||||
|
||||
if is_transcript:
|
||||
if is_transcript or is_pause_satellite:
|
||||
# Stop streaming before event_from_server is called because it will
|
||||
# play the "done" WAV.
|
||||
self.is_streaming = False
|
||||
@@ -1089,17 +1191,28 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
|
||||
await super().event_from_server(event)
|
||||
|
||||
if is_run_satellite or is_transcript or is_error:
|
||||
# Stop streaming and go back to wake word detection
|
||||
if is_run_satellite or is_transcript or is_error or is_pause_satellite:
|
||||
# Stop streaming
|
||||
self.is_streaming = False
|
||||
await self.trigger_streaming_stop()
|
||||
await self._send_wake_detect()
|
||||
_LOGGER.info("Waiting for wake word")
|
||||
|
||||
# Start debug recording (wake)
|
||||
self._debug_recording_timestamp = time.monotonic_ns()
|
||||
if self.wake_audio_writer is not None:
|
||||
self.wake_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
||||
if is_pause_satellite:
|
||||
self._is_paused = True
|
||||
_LOGGER.debug("Satellite is paused")
|
||||
else:
|
||||
# Go back to wake word detection
|
||||
await self.trigger_streaming_stop()
|
||||
|
||||
# It's possible to be paused in the middle of streaming
|
||||
if not self._is_paused:
|
||||
await self._send_wake_detect()
|
||||
_LOGGER.info("Waiting for wake word")
|
||||
|
||||
# Start debug recording (wake)
|
||||
self._debug_recording_timestamp = time.monotonic_ns()
|
||||
if self.wake_audio_writer is not None:
|
||||
self.wake_audio_writer.start(
|
||||
timestamp=self._debug_recording_timestamp
|
||||
)
|
||||
|
||||
async def trigger_server_disonnected(self) -> None:
|
||||
await super().trigger_server_disonnected()
|
||||
@@ -1115,7 +1228,11 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
async def event_from_mic(
|
||||
self, event: Event, audio_bytes: Optional[bytes] = None
|
||||
) -> None:
|
||||
if (not AudioChunk.is_type(event.type)) or self.microphone_muted:
|
||||
if (
|
||||
(not AudioChunk.is_type(event.type))
|
||||
or self.microphone_muted
|
||||
or self._is_paused
|
||||
):
|
||||
return
|
||||
|
||||
# Debug audio recording
|
||||
@@ -1138,8 +1255,13 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
await self.event_to_wake(event)
|
||||
|
||||
async def event_from_wake(self, event: Event) -> None:
|
||||
if Info.is_type(event.type):
|
||||
self._wake_info = Info.from_event(event)
|
||||
self._wake_info_ready.set()
|
||||
return
|
||||
|
||||
if self.is_streaming or (self.server_id is None):
|
||||
# Not streaming or no server connected
|
||||
# Not detecting or no server connected
|
||||
return
|
||||
|
||||
if Detection.is_type(event.type):
|
||||
@@ -1161,6 +1283,8 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
if self.stt_audio_writer is not None:
|
||||
self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp)
|
||||
|
||||
_LOGGER.debug(detection)
|
||||
|
||||
self.is_streaming = True
|
||||
_LOGGER.debug("Streaming audio")
|
||||
|
||||
@@ -1174,7 +1298,35 @@ class WakeStreamingSatellite(SatelliteBase):
|
||||
# No refractory period
|
||||
self.refractory_timestamp.pop(detection.name, None)
|
||||
|
||||
await self._send_run_pipeline()
|
||||
# Forward to the server
|
||||
await self.event_to_server(event)
|
||||
|
||||
# Match detected wake word name with pipeline name
|
||||
pipeline_name: Optional[str] = None
|
||||
if self.settings.wake.names:
|
||||
detection_name = normalize_wake_word(detection.name)
|
||||
for wake_name in self.settings.wake.names:
|
||||
if normalize_wake_word(wake_name.name) == detection_name:
|
||||
pipeline_name = wake_name.pipeline
|
||||
break
|
||||
|
||||
await self._send_run_pipeline(pipeline_name=pipeline_name)
|
||||
await self.forward_event(event) # forward to event service
|
||||
await self.trigger_detection(Detection.from_event(event))
|
||||
await self.trigger_streaming_start()
|
||||
|
||||
async def update_info(self, info: Info) -> None:
|
||||
self._wake_info = None
|
||||
self._wake_info_ready.clear()
|
||||
await self.event_to_wake(Describe().event())
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._wake_info_ready.wait(), timeout=_WAKE_INFO_TIMEOUT
|
||||
)
|
||||
|
||||
if self._wake_info is not None:
|
||||
# Update wake info only
|
||||
info.wake = self._wake_info.wake
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning("Failed to get info from wake service")
|
||||
|
||||
@@ -55,6 +55,9 @@ class MicSettings(ServiceSettings):
|
||||
seconds_to_mute_after_awake_wav: float = 0.5
|
||||
"""Extra second(s) of microphone audio to mute after awake WAV has finished playing."""
|
||||
|
||||
channel_index: Optional[int] = None
|
||||
"""Index of microphone channel to use. When not set, all input channels are used as-is."""
|
||||
|
||||
@property
|
||||
def needs_webrtc(self) -> bool:
|
||||
"""True if webrtc audio enhancements are needed."""
|
||||
@@ -100,11 +103,19 @@ class SndSettings(ServiceSettings):
|
||||
return self.enabled and (self.volume_multiplier != 1.0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeWordAndPipeline:
|
||||
"""Wake word name + optional pipeline name."""
|
||||
|
||||
name: str
|
||||
pipeline: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WakeSettings(ServiceSettings):
|
||||
"""Wake word service settings."""
|
||||
|
||||
names: Optional[List[str]] = None
|
||||
names: Optional[List[WakeWordAndPipeline]] = None
|
||||
"""List of wake word names to listen for."""
|
||||
|
||||
rate: int = 16000
|
||||
@@ -149,6 +160,7 @@ class EventSettings(ServiceSettings):
|
||||
streaming_stop: Optional[List[str]] = None
|
||||
detect: Optional[List[str]] = None
|
||||
detection: Optional[List[str]] = None
|
||||
played: Optional[List[str]] = None
|
||||
transcript: Optional[List[str]] = None
|
||||
stt_start: Optional[List[str]] = None
|
||||
stt_stop: Optional[List[str]] = None
|
||||
|
||||
@@ -10,6 +10,7 @@ from .misc import (
|
||||
get_mac_address,
|
||||
needs_silero,
|
||||
needs_webrtc,
|
||||
normalize_wake_word,
|
||||
run_event_command,
|
||||
split_command,
|
||||
)
|
||||
@@ -22,6 +23,7 @@ __all__ = [
|
||||
"multiply_volume",
|
||||
"needs_silero",
|
||||
"needs_webrtc",
|
||||
"normalize_wake_word",
|
||||
"run_event_command",
|
||||
"split_command",
|
||||
"wav_to_events",
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import unicodedata
|
||||
import uuid
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
|
||||
_LOGGER = logging.getLogger()
|
||||
@@ -56,3 +59,24 @@ def split_command(command: Optional[str]) -> Optional[List[str]]:
|
||||
return None
|
||||
|
||||
return shlex.split(command)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def normalize_wake_word(wake_word: str) -> str:
|
||||
"""Normalizes a wake word name for comparison."""
|
||||
|
||||
# Lower case
|
||||
wake_word = wake_word.strip().casefold()
|
||||
|
||||
# Remove version numbers like v1.0
|
||||
wake_word = re.sub(r"v[0-9]+(\.[0-9])+", "", wake_word)
|
||||
|
||||
# Replace anything besides letters/numbers with whitespace
|
||||
wake_word = "".join(
|
||||
c if unicodedata.category(c) in ("Ll", "Nd") else " " for c in wake_word
|
||||
)
|
||||
|
||||
# Normalize whitespace
|
||||
wake_word = " ".join(wake_word.strip().split())
|
||||
|
||||
return wake_word
|
||||
|
||||
Reference in New Issue
Block a user