#!/usr/bin/env python3 import argparse import asyncio import logging import math import shlex import sys import time import uuid import wave from enum import Enum from functools import partial from pathlib import Path from typing import Callable, Final, Iterator, Optional, Union from pyring_buffer import RingBuffer from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncClient from wyoming.error import Error 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_START = "wake-start" WAKE = "wake" ASR = "asr" VAD_RESET = "vad-reset" VAD_WAIT = "vad-wait" PLAYING_AUDIO = "playing-audio" class ServerState: current_client_id: Optional[str] = None 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)", ) parser.add_argument("--mic-volume-multiplier", type=float, default=1.0) # 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)", ) parser.add_argument("--snd-volume-multiplier", type=float, default=1.0) # 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", action="store_true", help="Wait for speech before streaming audio" ) parser.add_argument("--vad-threshold", type=float, default=0.5) parser.add_argument("--vad-trigger-level", type=int, default=1) parser.add_argument("--vad-buffer-seconds", type=float, default=2) parser.add_argument( "--vad-wake-word-timeout", type=float, default=5.0, help="Seconds before going back to waiting for speech when wake word isn't detected", ) # 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))) # External event handlers parser.add_argument( "--event-uri", help="URI of Wyoming service to forward events to" ) parser.add_argument( "--startup-command", help="Command run when the satellite starts" ) parser.add_argument( "--detect-command", help="Command to run when wake word detection starts" ) parser.add_argument( "--detection-command", help="Command to run when wake word is detected" ) parser.add_argument( "--transcript-command", help="Command to run when speech to text transcript is returned", ) parser.add_argument( "--stt-start-command", help="Command to run when the user starts speaking", ) parser.add_argument( "--stt-stop-command", help="Command to run when the user stops speaking", ) parser.add_argument( "--synthesize-command", help="Command to run when text to speech text is returned", ) parser.add_argument( "--tts-start-command", help="Command to run when text to speech response starts", ) parser.add_argument( "--tts-stop-command", help="Command to run when text to speech response stops", ) parser.add_argument( "--streaming-start-command", help="Command to run when audio streaming starts", ) parser.add_argument( "--error-command", help="Command to run when an error occurs", ) # Sounds parser.add_argument( "--awake-wav", help="WAV file to play when wake word is detected" ) parser.add_argument( "--done-wav", help="WAV file to play when voice command is done" ) # 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( "--zeroconf-host", help="Host address for zeroconf discovery (default: detect)", ) # 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 silerovad") sys.exit(1) if args.awake_wav and (not Path(args.awake_wav).is_file()): _LOGGER.fatal("%s does not exist", args.awake_wav) sys.exit(1) if args.done_wav and (not Path(args.done_wav).is_file()): _LOGGER.fatal("%s does not exist", args.done_wav) sys.exit(1) 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) _LOGGER.debug(args) wyoming_info = Info( satellite=Satellite( name=args.name, area=args.area, description=args.name, attribution=Attribution(name="", url=""), installed=True, ) ) if args.startup_command: await run_event_command(args.startup_command) _LOGGER.info("Ready") # Start server server = AsyncServer.from_uri(args.uri) server_state = ServerState() 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=args.zeroconf_host, ) _LOGGER.debug( "Zeroconf discovery enabled (name=%s, host=%s)", args.zeroconf_name, args.zeroconf_host, ) try: await server.run( partial(SatelliteEventHandler, wyoming_info, server_state, args) ) except KeyboardInterrupt: pass # ----------------------------------------------------------------------------- class SatelliteEventHandler(AsyncEventHandler): """Event handler for clients.""" def __init__( self, wyoming_info: Info, server_state: ServerState, 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.is_running = True self.state: Optional[Satellite] = None self.after_tts_state: Optional[Satellite] = None self.satellite_task: Optional[asyncio.Task] = None self.mic_volume_multiplier = self.cli_args.mic_volume_multiplier self.has_mic_audio_processing = needs_webrtc(self.cli_args) or ( self.mic_volume_multiplier != 1.0 ) 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 ) 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.snd_volume_multiplier = self.cli_args.snd_volume_multiplier self.has_snd_audio_processing = self.snd_volume_multiplier != 1.0 self.process_vad: Optional[Callable[[Optional[bytes]], bool]] = None self.vad_buffer: Optional[RingBuffer] = None if needs_silero(self.cli_args): self.process_vad = SileroVad( self.cli_args.vad_threshold, self.cli_args.vad_trigger_level ) if self.cli_args.vad_buffer_seconds > 0: # Assume 16Khz, 16-bit mono samples vad_buffer_bytes = int( math.ceil(self.cli_args.vad_buffer_seconds * 16000 * 2) ) self.vad_buffer = RingBuffer(vad_buffer_bytes) self.events_client: Optional[AsyncClient] = None self.server_state = server_state self.server_state.current_client_id = self.client_id _LOGGER.debug("Client connected: %s", self.client_id) self._stop_if_new_client_task = asyncio.create_task(self._stop_if_new_client()) async def _stop_if_new_client(self) -> None: """Stop this event handler if a new client connects.""" while self.is_running: if self.server_state.current_client_id != self.client_id: self.is_running = False _LOGGER.debug("Detected new client. Stopping old client.") else: await asyncio.sleep(1) # ------------------------------------------------------------------------- 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) _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 self.state = SatelliteState.WAKE_START self.satellite_task = asyncio.create_task(self.run_satellite_wake()) else: # Remote wake word detection self.state = SatelliteState.VAD_RESET self.satellite_task = asyncio.create_task(self.run_satellite()) elif Transcript.is_type(event.type): if self.cli_args.done_wav: self.state = SatelliteState.PLAYING_AUDIO await self._play_wav(self.cli_args.done_wav) # STT transcript await self._forward_event(event) _LOGGER.debug("-> %s", event) if self.has_wake: # Ready for next wake word detection self.state = SatelliteState.WAKE_START else: self.state = SatelliteState.VAD_RESET elif self.has_snd and ( AudioStart.is_type(event.type) or AudioChunk.is_type(event.type) or AudioStop.is_type(event.type) ): if AudioStart.is_type(event.type): # TTS start await self._ensure_snd_client() if self.snd_client is not None: await self.snd_client.connect() # State will be restored when audio ends self.after_tts_state = self.state self.state = SatelliteState.PLAYING_AUDIO await self._forward_event(event) _LOGGER.debug("-> %s", event) elif AudioChunk.is_type(event.type) and (self.snd_client is not None): # Forward to sound service event = self._process_snd_audio(event) await self.snd_client.write_event(event) elif AudioStop.is_type(event.type): # TTS stop await self._forward_event(event) _LOGGER.debug("-> %s", event) if self.snd_client is not None: await self.snd_client.disconnect() self.snd_client = None if self.after_tts_state is not None: # Restore state after playing audio self.state = self.after_tts_state self.after_tts_state = None elif Detection.is_type(event.type): if self.cli_args.awake_wav: last_state = self.state self.state = SatelliteState.PLAYING_AUDIO await self._play_wav(self.cli_args.awake_wav) self.state = last_state await self._forward_event(event) _LOGGER.debug("-> %s", event) elif ( Detect.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 # - Transcribe for when STT starts # - VoiceStarted/VoiceStopped for when user starts/stops speaking # - Synthesize for TTS text await self._forward_event(event) _LOGGER.debug("-> %s", event) elif Error.is_type(event.type): await self._forward_event(event) _LOGGER.warning(event) else: _LOGGER.debug("Unexpected event: type=%s, data=%s", event.type, event.data) return True async def disconnect(self) -> None: self.is_running = False # ------------------------------------------------------------------------- # Tasks # ------------------------------------------------------------------------- async def run_satellite(self) -> None: """Task to read mic input and do remote wake word detection.""" vad_timeout_sec: Optional[float] = None try: mic_client = self._make_mic_client() async with mic_client: while self.is_running: mic_event = await mic_client.read_event() if mic_event is None: _LOGGER.warning("Microphone service disconnected") break if not AudioChunk.is_type(mic_event.type): continue mic_event = self._process_mic_audio(mic_event) if self.state == SatelliteState.VAD_RESET: # Reset vad state if self.process_vad is not None: self.process_vad(None) if self.vad_buffer is not None: self.vad_buffer.put(bytes(self.vad_buffer.maxlen)) self.state = SatelliteState.VAD_WAIT if self.process_vad is not None: _LOGGER.info("Waiting for speech") if self.state == SatelliteState.VAD_WAIT: if self.process_vad is None: # No VAD self.state = SatelliteState.ASR _LOGGER.info("Streaming audio") # Start pipeline await self._run_pipeline(PipelineStage.WAKE) else: # Wait for speech chunk = AudioChunk.from_event(mic_event) if self.process_vad(chunk.audio): # Ready to stream self.state = SatelliteState.WAKE # Start pipeline await self._run_pipeline(PipelineStage.WAKE) if self.vad_buffer: # Send contents of VAD buffer first await self.write_event( AudioChunk( rate=chunk.rate, width=chunk.width, channels=chunk.channels, audio=self.vad_buffer.getvalue(), ).event() ) if self.cli_args.vad_wake_word_timeout is not None: # Time in the future when timeout will occur vad_timeout_sec = ( time.monotonic() + self.cli_args.vad_wake_word_timeout ) else: # No timeout vad_timeout_sec = None _LOGGER.info("Streaming audio") elif self.vad_buffer is not None: # Save audio right before speech self.vad_buffer.put(chunk.audio) if self.state == SatelliteState.WAKE: if (vad_timeout_sec is not None) and ( time.monotonic() >= vad_timeout_sec ): # Wake word was not detected within timeout self.state = SatelliteState.VAD_RESET # Cancel current pipeline with empty audio chunk chunk = AudioChunk.from_event(mic_event) await self.write_event( AudioChunk( rate=chunk.rate, width=chunk.width, channels=chunk.channels, audio=bytes(), ).event() ) continue # Remote wake word detection await self.write_event(mic_event) elif self.state == SatelliteState.ASR: # Forward all audio to server await self.write_event(mic_event) except ConnectionResetError: _LOGGER.info("Server disconnected") except Exception: _LOGGER.exception("Unexpected error in run_satellite") async def run_satellite_wake(self) -> None: """Task to read mic input and do local wake word detection.""" 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: # 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: if self.state == SatelliteState.WAKE_START: # Send detect event _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() _LOGGER.debug("<- %s", detect_event) await wake_client.write_event(detect_event) await self._forward_event(detect_event) self.state = SatelliteState.WAKE # Wait for mic/wake events 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_mic_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) if self.cli_args.awake_wav: self.state = SatelliteState.PLAYING_AUDIO await self._play_wav(self.cli_args.awake_wav) self.state = SatelliteState.ASR # Start pipeline await self._run_pipeline(PipelineStage.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 ConnectionResetError: _LOGGER.info("Server disconnected") except Exception: _LOGGER.exception("Unexpected error in run_satellite") # ------------------------------------------------------------------------- def _make_mic_client(self) -> AsyncClient: """Create mic client.""" 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 _ensure_snd_client(self) -> None: """Create snd client if necessary.""" if (not self.has_snd) or (self.snd_client is not None): return 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) async def _run_pipeline(self, start_stage: PipelineStage) -> None: """Tell server to start running the pipeline.""" await self.write_event( RunPipeline( start_stage=start_stage, end_stage=PipelineStage.TTS if self.has_snd else PipelineStage.HANDLE, name=self.cli_args.pipeline, ).event() ) if self.cli_args.streaming_start_command: await run_event_command(self.cli_args.streaming_start_command) def _process_mic_audio(self, audio_event: Event) -> Event: """Perform microphone audio processing, if necessary.""" if not self.has_mic_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.mic_volume_multiplier != 1.0: audio_bytes = multiply_volume(audio_bytes, self.mic_volume_multiplier) return AudioChunk( rate=chunk.rate, width=chunk.width, channels=chunk.channels, audio=audio_bytes, ).event() def _process_snd_audio(self, audio_event: Event) -> Event: """Perform output audio processing, if necessary.""" if not self.has_snd_audio_processing: # No processing needed return audio_event chunk = AudioChunk.from_event(audio_event) audio_bytes = chunk.audio if self.snd_volume_multiplier != 1.0: audio_bytes = multiply_volume(audio_bytes, self.snd_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 and run event commands.""" if self.cli_args.detect_command and Detect.is_type(event.type): # Wake word detection started await run_event_command(self.cli_args.detect_command) elif self.cli_args.detection_command and Detection.is_type(event.type): # Wake word is detected detection = Detection.from_event(event) await run_event_command(self.cli_args.detection_command, detection.name) elif self.cli_args.transcript_command and Transcript.is_type(event.type): # STT text is available transcript = Transcript.from_event(event) await run_event_command(self.cli_args.transcript_command, transcript.text) elif self.cli_args.stt_start_command and VoiceStarted.is_type(event.type): # User starts speaking await run_event_command(self.cli_args.stt_start_command) elif self.cli_args.stt_stop_command and VoiceStopped.is_type(event.type): # User stops speaking await run_event_command(self.cli_args.stt_stop_command) elif self.cli_args.synthesize_command and Synthesize.is_type(event.type): # TTS text is available synthesize = Synthesize.from_event(event) await run_event_command(self.cli_args.synthesize_command, synthesize.text) elif self.cli_args.tts_start_command and AudioStart.is_type(event.type): # TTS audio start await run_event_command(self.cli_args.tts_start_command) elif self.cli_args.tts_stop_command and AudioStop.is_type(event.type): # TTS audio stop await run_event_command(self.cli_args.tts_stop_command) elif self.cli_args.error_command and Error.is_type(event.type): # Error occurred error = Error.from_event(event) await run_event_command(self.cli_args.error_command, error.text) if not self.cli_args.event_uri: # No external service 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 _play_wav(self, wav_path: Union[str, Path]) -> None: """Send WAV file to snd client as audio chunks.""" await self._ensure_snd_client() if self.snd_client is None: _LOGGER.debug("Cannot play WAV, no snd service") return try: async with self.snd_client: for event in self._wav_to_events(wav_path): await self.snd_client.write_event(event) except Exception: _LOGGER.exception("Unexpected error while playing WAV: %s", wav_path) finally: self.snd_client = None def _wav_to_events( self, wav_path: Union[str, Path], samples_per_chunk: int = 1024 ) -> Iterator[Event]: """Load WAV audio for playback on an event (wake/done).""" with wave.open(str(wav_path), "rb") as wav_file: rate = wav_file.getframerate() width = wav_file.getsampwidth() channels = wav_file.getnchannels() timestamp = 0 yield AudioStart( rate=rate, width=width, channels=channels, timestamp=timestamp ).event() audio_bytes = wav_file.readframes(samples_per_chunk) while audio_bytes: if self.snd_volume_multiplier != 1.0: audio_bytes = multiply_volume( audio_bytes, self.snd_volume_multiplier ) chunk = AudioChunk( rate=rate, width=width, channels=channels, audio=audio_bytes, timestamp=timestamp, ) yield chunk.event() timestamp += int(chunk.seconds * 1000) audio_bytes = wav_file.readframes(samples_per_chunk) yield AudioStop(timestamp=timestamp).event() # ----------------------------------------------------------------------------- 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) def needs_silero(args: argparse.Namespace) -> bool: """Return True if silero-vad must be used.""" return args.vad async def run_event_command(command: str, command_input: Optional[str] = None) -> None: """Run a custom event command with optional input.""" if not command: return _LOGGER.debug("Running %s", command) program, *program_args = shlex.split(command) proc = await asyncio.create_subprocess_exec( program, *program_args, stdin=asyncio.subprocess.PIPE ) assert proc.stdin is not None if command_input: await proc.communicate(input=command_input.encode("utf-8")) else: proc.stdin.close() await proc.wait() class WebRtcAudio: """Audio processing using webrtc.""" _sub_chunk_samples: Final = 160 # 10ms @ 16Khz _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: """Process in 10ms chunks.""" 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 class SileroVad: """Voice activity detection with silero VAD.""" def __init__(self, threshold: float, trigger_level: int) -> None: from pysilero_vad import SileroVoiceActivityDetector self.detector = SileroVoiceActivityDetector() self.threshold = threshold self.trigger_level = trigger_level self._activation = 0 def __call__(self, audio_bytes: Optional[bytes]) -> bool: if audio_bytes is None: # Reset self._activation = 0 self.detector.reset() return False if self.detector(audio_bytes) >= self.threshold: # Speech detected self._activation += 1 if self._activation >= self.trigger_level: self._activation = 0 return True else: # Silence detected self._activation = max(0, self._activation - 1) return False # ----------------------------------------------------------------------------- if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass