diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index 941a9c4..74f4289 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -24,6 +24,7 @@ from .settings import ( SndSettings, VadSettings, WakeSettings, + WakeWordAndPipeline, ) from .utils import ( get_mac_address, @@ -114,7 +115,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( @@ -329,7 +332,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, diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index 31aba25..492c54b 100644 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -6,11 +6,11 @@ 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 @@ -31,7 +31,13 @@ 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 @@ -285,7 +291,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 @@ -304,8 +310,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) @@ -734,7 +749,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() # ------------------------------------------------------------------------- @@ -1225,6 +1244,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") @@ -1238,7 +1259,16 @@ class WakeStreamingSatellite(SatelliteBase): # No refractory period self.refractory_timestamp.pop(detection.name, None) - await self._send_run_pipeline() + # 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() diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index 138f884..82084d8 100644 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -100,11 +100,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 diff --git a/wyoming_satellite/utils/__init__.py b/wyoming_satellite/utils/__init__.py index c91a7b7..e47c60b 100644 --- a/wyoming_satellite/utils/__init__.py +++ b/wyoming_satellite/utils/__init__.py @@ -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", diff --git a/wyoming_satellite/utils/misc.py b/wyoming_satellite/utils/misc.py index a5d59ff..5d39d14 100644 --- a/wyoming_satellite/utils/misc.py +++ b/wyoming_satellite/utils/misc.py @@ -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