#!/usr/bin/env python3 import argparse import asyncio import logging import shlex import sys import time import uuid from enum import Enum from functools import partial from pathlib import Path from typing import Callable, Final, Optional from wyoming.asr import Transcribe, 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.mic import MicProcessAsyncClient from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite from wyoming.server import AsyncEventHandler, AsyncServer, AsyncTcpServer from wyoming.snd import SndProcessAsyncClient from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection, WakeProcessAsyncClient from .utils import AudioBuffer, chunk_samples, multiply_volume _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() # Microphone input parser.add_argument("--mic-uri", help="URI of Wyoming microphone service") parser.add_argument("--mic-command", help="Program to run for microphone input") parser.add_argument( "--mic-command-rate", type=int, default=16000, help="Sample rate of mic-command (hertz, default: 16000)", ) parser.add_argument( "--mic-command-width", type=int, default=2, help="Sample width of mic-command (bytes, default: 2)", ) parser.add_argument( "--mic-command-channels", type=int, default=1, help="Sample channels of mic-command (default: 1)", ) parser.add_argument( "--mic-command-samples-per-chunk", type=int, default=1024, help="Sample per chunk for mic-command (default: 1024)", ) # Sound output parser.add_argument("--snd-uri", help="URI of Wyoming sound service") parser.add_argument("--snd-command", help="Program to run for sound output") parser.add_argument( "--snd-command-rate", type=int, default=22050, help="Sample rate of snd-command (hertz, default: 22050)", ) parser.add_argument( "--snd-command-width", type=int, default=2, help="Sample width of snd-command (bytes, default: 2)", ) parser.add_argument( "--snd-command-channels", type=int, default=1, help="Sample channels of snd-command (default: 1)", ) # Local wake word detection 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("--wake-command", help="Program to run for wake word detection") parser.add_argument( "--wake-command-rate", type=int, default=16000, help="Sample rate of wake-command (hertz, default: 16000)", ) parser.add_argument( "--wake-command-width", type=int, default=2, help="Sample width of wake-command (bytes, default: 2)", ) parser.add_argument( "--wake-command-channels", type=int, default=1, help="Sample channels of wake-command (default: 1)", ) # Voice activity detector parser.add_argument( "--vad", choices=("disabled", "webrtcvad", "silero"), default="disabled" ) parser.add_argument("--vad-threshold", type=float, default=0.5) parser.add_argument("--vad-trigger-level", type=int, default=3) parser.add_argument("--vad-buffer-chunks", type=int, default=40) # Audio enhancement (requires webrtc-noise-audio) parser.add_argument( "--noise-suppression", type=int, default=0, choices=(0, 1, 2, 3, 4) ) parser.add_argument("--auto-gain", type=int, default=0, choices=list(range(32))) parser.add_argument("--volume-multiplier", type=float, default=1.0) # External event handlers parser.add_argument( "--event-uri", help="URI of Wyoming service to forward events to" ) # Satellite details 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") # Zeroconf 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: MAC from uuid.getnode)", ) # parser.add_argument("--debug", action="store_true", help="Log DEBUG messages") args = parser.parse_args() # Validate args if (not args.mic_uri) and (not args.mic_command): _LOGGER.fatal("Either --mic-uri or --mic-command is required") sys.exit(1) if needs_webrtc(args): try: import webrtc_noise_gain # noqa: F401 except ImportError: _LOGGER.fatal("Install extras for webrtc") sys.exit(1) if needs_silero(args): try: import pysilero_vad # noqa: F401 except ImportError: _LOGGER.fatal("Install extras for silero") sys.exit(1) 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 if not args.zeroconf_name: args.zeroconf_name = get_mac_address() tcp_server: AsyncTcpServer = server await register_server( name=args.zeroconf_name, port=tcp_server.port, host=tcp_server.host, ) _LOGGER.debug("Zeroconf discovery enabled (name=%s)", args.zeroconf_name) 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.volume_multiplier = self.cli_args.volume_multiplier self.has_audio_processing = needs_webrtc(self.cli_args) or ( self.volume_multiplier != 1.0 ) self.has_wake = bool(self.cli_args.wake_uri or self.cli_args.wake_command) self.has_snd = bool(self.cli_args.snd_uri or self.cli_args.snd_command) self.snd_client: Optional[AsyncClient] = None self.events_client: Optional[AsyncClient] = None self.is_running = True self.process_webrtc: Optional[Callable[[bytes], bytes]] = None if needs_webrtc(self.cli_args): self.process_webrtc = WebRtcAudio( self.cli_args.auto_gain, self.cli_args.noise_suppression ) _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.has_wake: # 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) _LOGGER.debug(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: if self.cli_args.snd_command: _LOGGER.debug("Running %s", self.cli_args.snd_command) program, *program_args = shlex.split(self.cli_args.snd_command) self.snd_client = SndProcessAsyncClient( rate=self.cli_args.snd_command_rate, width=self.cli_args.snd_command_width, channels=self.cli_args.snd_command_channels, program=program, program_args=program_args, ) else: _LOGGER.debug("Connecting to %s", self.cli_args.snd_uri) 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) _LOGGER.debug(event) elif ( Detect.is_type(event.type) or Detection.is_type(event.type) or Transcribe.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 # - Transcribe for when STT starts # - VoiceStarted/VoiceStopped for when user starts/stops speaking # - Synthesize for TTS text await self.forward_event(event) _LOGGER.debug(event) else: _LOGGER.debug("Unexpected event: type=%s, data=%s", event.type, event.data) return True def _make_mic_client(self) -> AsyncClient: if self.cli_args.mic_command: _LOGGER.debug("Running %s", self.cli_args.mic_command) program, *program_args = shlex.split(self.cli_args.mic_command) return MicProcessAsyncClient( rate=self.cli_args.mic_command_rate, width=self.cli_args.mic_command_width, channels=self.cli_args.mic_command_channels, samples_per_chunk=self.cli_args.mic_command_samples_per_chunk, program=program, program_args=program_args, ) assert self.cli_args.mic_uri _LOGGER.debug("Connecting to %s", self.cli_args.mic_uri) return AsyncClient.from_uri(self.cli_args.mic_uri) async def run_satellite(self) -> None: try: mic_client = self._make_mic_client() async with 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): mic_event = self._process_audio(mic_event) # 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 or self.cli_args.wake_command mic_client = self._make_mic_client() if self.cli_args.wake_command: _LOGGER.debug("Running %s", self.cli_args.wake_command) program, *program_args = shlex.split(self.cli_args.wake_command) wake_client = WakeProcessAsyncClient( rate=self.cli_args.wake_command_rate, width=self.cli_args.wake_command_width, channels=self.cli_args.wake_command_channels, program=program, program_args=program_args, ) else: _LOGGER.debug("Connecting to %s", self.cli_args.wake_uri) wake_client = AsyncClient.from_uri(self.cli_args.wake_uri) async with mic_client, 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) _LOGGER.debug(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): mic_event = self._process_audio(mic_event) # 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") def _process_audio(self, audio_event: Event) -> Event: """Perform audio processing.""" if not self.has_audio_processing: # No processing needed return audio_event chunk = AudioChunk.from_event(audio_event) audio_bytes = chunk.audio if self.process_webrtc is not None: audio_bytes = self.process_webrtc(audio_bytes) if self.volume_multiplier != 1.0: audio_bytes = multiply_volume(audio_bytes, self.volume_multiplier) return AudioChunk( rate=chunk.rate, width=chunk.width, channels=chunk.channels, audio=audio_bytes, ).event() 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 # ----------------------------------------------------------------------------- def get_mac_address() -> str: """Return MAC address formatted as hex with no colons.""" return "".join( # pylint: disable=consider-using-f-string ["{:02x}".format((uuid.getnode() >> ele) & 0xFF) for ele in range(0, 8 * 6, 8)][ ::-1 ] ) def needs_webrtc(args: argparse.Namespace) -> bool: """Return True if webrtc must be used.""" return ( (args.noise_suppression > 0) or (args.auto_gain > 0) or (args.vad == "webrtc") ) def needs_silero(args: argparse.Namespace) -> bool: """Return True if silero-vad must be used.""" return args.vad == "silero" class WebRtcAudio: _sub_chunk_samples: Final = 160 _sub_chunk_bytes: Final = _sub_chunk_samples * 2 # 16-bit def __init__(self, auto_gain: int, noise_suppression: int) -> None: from webrtc_noise_gain import AudioProcessor self.audio_processor = AudioProcessor(auto_gain, noise_suppression) self.audio_buffer = AudioBuffer(self._sub_chunk_bytes) def __call__(self, audio_bytes: bytes) -> bytes: clean_chunk = bytes() for sub_chunk in chunk_samples( audio_bytes, self._sub_chunk_bytes, self.audio_buffer ): result = self.audio_processor.Process10ms(sub_chunk) clean_chunk += result.audio return clean_chunk # ----------------------------------------------------------------------------- if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass