diff --git a/examples/2mic_service.py b/examples/2mic_service.py index 61bcbfd..ac58c4a 100644 --- a/examples/2mic_service.py +++ b/examples/2mic_service.py @@ -14,10 +14,10 @@ from wyoming.asr import Transcript from wyoming.event import Event from wyoming.satellite import ( RunSatellite, - StreamingStarted, - StreamingStopped, SatelliteConnected, SatelliteDisconnected, + StreamingStarted, + StreamingStopped, ) from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.vad import VoiceStarted @@ -115,7 +115,7 @@ class LEDsEventHandler(AsyncEventHandler): self.color(_BLACK) elif SatelliteConnected.is_type(event.type): # Flash - for _ in range(5): + for _ in range(3): self.color(_GREEN) await asyncio.sleep(0.3) self.color(_BLACK) diff --git a/examples/4mic_service.py b/examples/4mic_service.py index c567283..221528f 100644 --- a/examples/4mic_service.py +++ b/examples/4mic_service.py @@ -14,10 +14,10 @@ from wyoming.asr import Transcript from wyoming.event import Event from wyoming.satellite import ( RunSatellite, - StreamingStarted, - StreamingStopped, SatelliteConnected, SatelliteDisconnected, + StreamingStarted, + StreamingStopped, ) from wyoming.server import AsyncEventHandler, AsyncServer from wyoming.vad import VoiceStarted @@ -115,7 +115,7 @@ class LEDsEventHandler(AsyncEventHandler): self.color(_BLACK) elif SatelliteConnected.is_type(event.type): # Flash - for _ in range(5): + for _ in range(3): self.color(_GREEN) await asyncio.sleep(0.3) self.color(_BLACK) diff --git a/installer/__main__.py b/installer/__main__.py index 9732ed7..7baa579 100644 --- a/installer/__main__.py +++ b/installer/__main__.py @@ -119,12 +119,13 @@ def apply_settings(settings: Settings) -> None: error("installing audio enhancements") return - if settings.satellite.event_service_command and (("2mic" in settings.satellite.event_service_command) or ("4mic" in settings.satellite.event_service_command)): + if settings.satellite.event_service_command and ( + ("2mic" in settings.satellite.event_service_command) + or ("4mic" in settings.satellite.event_service_command) + ): result = run_with_gauge( "Installing event requirements...", - [ - pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt")) - ], + [pip_install("-r", str(PROGRAM_DIR / "requirements_respeaker.txt"))], ) if not result: error("installing event requirements") diff --git a/installer/satellite.py b/installer/satellite.py index cb72cdb..48287de 100644 --- a/installer/satellite.py +++ b/installer/satellite.py @@ -1,7 +1,7 @@ """Satellite settings.""" from typing import Optional -from .const import SatelliteType, Settings, PROGRAM_DIR +from .const import PROGRAM_DIR, SatelliteType, Settings from .whiptail import error, inputbox, menu, passwordbox, radiolist, run_with_gauge diff --git a/wyoming_satellite/satellite.py b/wyoming_satellite/satellite.py index ec66f86..5b41f54 100644 --- a/wyoming_satellite/satellite.py +++ b/wyoming_satellite/satellite.py @@ -3,9 +3,10 @@ import asyncio import logging import math import time +import wave from enum import Enum, auto from pathlib import Path -from typing import Callable, Dict, Optional, Set, Union, Final +from typing import Callable, Dict, Final, Optional, Set, Union from pyring_buffer import RingBuffer from wyoming.asr import Transcript @@ -14,19 +15,19 @@ from wyoming.client import AsyncClient from wyoming.error import Error from wyoming.event import Event, async_write_event from wyoming.mic import MicProcessAsyncClient +from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import ( RunSatellite, - StreamingStarted, - StreamingStopped, SatelliteConnected, SatelliteDisconnected, + StreamingStarted, + StreamingStopped, ) from wyoming.snd import SndProcessAsyncClient from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection, WakeProcessAsyncClient -from wyoming.ping import Ping, Pong from .settings import SatelliteSettings from .utils import DebugAudioWriter, multiply_volume, run_event_command, wav_to_events @@ -73,6 +74,9 @@ class SatelliteBase: self._pong_received_event = asyncio.Event() self._ping_server_task = asyncio.create_task(self._ping_server(), name="ping") + self.microphone_muted = False + self._unmute_microphone_task: Optional[asyncio.Task] = None + # Debug audio recording self.wake_audio_writer: Optional[DebugAudioWriter] = None self.stt_audio_writer: Optional[DebugAudioWriter] = None @@ -545,17 +549,41 @@ class SatelliteBase: return audio_bytes - async def _play_wav(self, wav_path: Optional[Union[str, Path]]) -> None: + async def _play_wav( + self, wav_path: Optional[Union[str, Path]], mute_microphone: bool = False + ) -> None: """Send WAV as events to sound service.""" if (not wav_path) or (not self.settings.snd.enabled): return - for event in wav_to_events( - wav_path, - samples_per_chunk=self.settings.snd.samples_per_chunk, - volume_multiplier=self.settings.snd.volume_multiplier, - ): - await self.event_to_snd(event) + try: + if mute_microphone: + with wave.open(wav_path, "rb") as wav_file: + seconds_to_mute = wav_file.getnframes() / wav_file.getframerate() + + seconds_to_mute += self.settings.mic.seconds_to_mute_after_awake_wav + _LOGGER.debug("Muting microphone for %s second(s)", seconds_to_mute) + self.microphone_muted = True + self._unmute_microphone_task = asyncio.create_task( + self._unmute_microphone_after(seconds_to_mute) + ) + + for event in wav_to_events( + wav_path, + samples_per_chunk=self.settings.snd.samples_per_chunk, + volume_multiplier=self.settings.snd.volume_multiplier, + ): + await self.event_to_snd(event) + except Exception: + # Unmute in case of an error + self.microphone_muted = False + + raise + + async def _unmute_microphone_after(self, seconds: float) -> None: + await asyncio.sleep(seconds) + self.microphone_muted = False + _LOGGER.debug("Unmuted microphone") # ------------------------------------------------------------------------- # Wake @@ -719,7 +747,10 @@ class SatelliteBase: async def trigger_detection(self, detection: Detection) -> None: """Called when wake word is detected.""" await run_event_command(self.settings.event.detection, detection.name) - await self._play_wav(self.settings.snd.awake_wav) + await self._play_wav( + self.settings.snd.awake_wav, + mute_microphone=self.settings.mic.mute_during_awake_wav, + ) async def trigger_transcript(self, transcript: Transcript) -> None: """Called when speech-to-text text is received.""" @@ -827,22 +858,23 @@ class AlwaysStreamingSatellite(SatelliteBase): # Start debug recording if self.stt_audio_writer is not None: self.stt_audio_writer.start() - elif Transcript.is_type(event.type): + elif Transcript.is_type(event.type) or Error.is_type(event.type): # Stop debug recording if self.stt_audio_writer is not None: self.stt_audio_writer.stop() - # We're always streaming - _LOGGER.info("Streaming audio") + if Transcript.is_type(event.type): + # We're always streaming + _LOGGER.info("Streaming audio") - # Re-trigger streaming start even though we technically don't stop - # so the event service can reset LEDs, etc. - await self.trigger_streaming_start() + # Re-trigger streaming start even though we technically don't stop + # so the event service can reset LEDs, etc. + await self.trigger_streaming_start() async def event_from_mic( self, event: Event, audio_bytes: Optional[bytes] = None ) -> None: - if not self.is_streaming: + if (not self.is_streaming) or self.microphone_muted: return if AudioChunk.is_type(event.type): @@ -898,7 +930,9 @@ class VadStreamingSatellite(SatelliteBase): # Start debug recording if self.stt_audio_writer is not None: self.stt_audio_writer.start() - elif Transcript.is_type(event.type): + elif Transcript.is_type(event.type) or Error.is_type(event.type): + self.is_streaming = False + # Stop debug recording if self.stt_audio_writer is not None: self.stt_audio_writer.stop() @@ -906,7 +940,7 @@ class VadStreamingSatellite(SatelliteBase): async def event_from_mic( self, event: Event, audio_bytes: Optional[bytes] = None ) -> None: - if not AudioChunk.is_type(event.type): + if (not AudioChunk.is_type(event.type)) or self.microphone_muted: return # Only unpack chunk once @@ -1031,11 +1065,14 @@ class WakeStreamingSatellite(SatelliteBase): # Only check event types once is_run_satellite = False is_transcript = False + is_error = False if RunSatellite.is_type(event.type): is_run_satellite = True elif Transcript.is_type(event.type): is_transcript = True + elif Error.is_type(event.type): + is_error = True if is_transcript: # Stop streaming before event_from_server is called because it will @@ -1048,7 +1085,7 @@ class WakeStreamingSatellite(SatelliteBase): await super().event_from_server(event) - if is_run_satellite or is_transcript: + if is_run_satellite or is_transcript or is_error: # Stop streaming and go back to wake word detection self.is_streaming = False await self.trigger_streaming_stop() @@ -1074,7 +1111,7 @@ class WakeStreamingSatellite(SatelliteBase): async def event_from_mic( self, event: Event, audio_bytes: Optional[bytes] = None ) -> None: - if not AudioChunk.is_type(event.type): + if (not AudioChunk.is_type(event.type)) or self.microphone_muted: return # Debug audio recording diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index c9fdcf9..67e7bd7 100644 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -49,6 +49,12 @@ class MicSettings(ServiceSettings): samples_per_chunk: int = 1024 """Samples to read at a time from mic command""" + mute_during_awake_wav: bool = True + """True if microphone audio should be muted while the awake WAV is playing.""" + + seconds_to_mute_after_awake_wav: float = 0.5 + """Extra second(s) of microphone audio to mute after awake WAV has finished playing.""" + @property def needs_webrtc(self) -> bool: """True if webrtc audio enhancements are needed."""