From 456b7f1ce9cd6649c21c3f24a0797e2e2a410424 Mon Sep 17 00:00:00 2001 From: emcon Date: Thu, 2 Jan 2025 15:15:25 -0800 Subject: [PATCH] add fsm satellite for realtime conversations --- sounds/awake-quiet.wav | Bin 0 -> 17700 bytes sounds/done-quiet.wav | Bin 0 -> 49276 bytes wyoming_satellite/__main__.py | 62 +++- wyoming_satellite/fsmsat.py | 558 ++++++++++++++++++++++++++++++++++ wyoming_satellite/settings.py | 25 ++ 5 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 sounds/awake-quiet.wav create mode 100644 sounds/done-quiet.wav create mode 100644 wyoming_satellite/fsmsat.py diff --git a/sounds/awake-quiet.wav b/sounds/awake-quiet.wav new file mode 100644 index 0000000000000000000000000000000000000000..fd44565ecac3e03b5209cdda598f9d18731a9861 GIT binary patch literal 17700 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 literal 0 HcmV?d00001 diff --git a/wyoming_satellite/__main__.py b/wyoming_satellite/__main__.py index 4cff57b..4adf081 100644 --- a/wyoming_satellite/__main__.py +++ b/wyoming_satellite/__main__.py @@ -10,6 +10,8 @@ from wyoming.info import Attribution, Info, Satellite from wyoming.server import AsyncServer, AsyncTcpServer +from .fsmsat import FSMSatellite + from . import __version__ from .event_handler import SatelliteEventHandler from .satellite import ( @@ -20,6 +22,7 @@ ) from .settings import ( EventSettings, + FSMSettings, MicSettings, SatelliteSettings, SndSettings, @@ -166,6 +169,49 @@ async def main() -> None: help="Seconds before going back to waiting for speech when wake word isn't detected", ) + parser.add_argument( + "--fsm-stream-giveup-delay", + type=float, + default=5, + help=FSMSettings.stream_giveup_delay.__doc__, + ) + parser.add_argument( + "--fsm-stream-end-delay", + type=float, + default=1.2, + help=FSMSettings.stream_end_delay.__doc__, + ) + parser.add_argument( + "--fsm-tts-end-delay", + type=float, + default=0.5, + help=FSMSettings.tts_end_delay.__doc__, + ) + parser.add_argument( + "--fsm-followup-vad-refractory", + type=float, + default=1.5, + help=FSMSettings.followup_vad_refractory.__doc__, + ) + parser.add_argument( + "--fsm-followup-timeout", + type=float, + default=10, + help=FSMSettings.followup_timeout.__doc__, + ) + parser.add_argument( + "--fsm-listen-start-wav", + type=str, + default=None, + help=FSMSettings.listen_start_alert_wav.__doc__, + ) + parser.add_argument( + "--fsm-listen-stop-wav", + type=str, + default=None, + help=FSMSettings.listen_stop_alert_wav.__doc__, + ) + # External event handlers parser.add_argument( "--event-uri", help="URI of Wyoming service to forward events to" @@ -330,9 +376,6 @@ async def main() -> None: _LOGGER.fatal("%s does not exist", args.timer_finished_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, format=args.log_format ) @@ -424,12 +467,23 @@ async def main() -> None: finished_wav_plays=int(args.timer_finished_wav_repeat[0]), finished_wav_delay=args.timer_finished_wav_repeat[1], ), + fsm=FSMSettings( + followup_timeout=args.fsm_followup_timeout, + stream_giveup_delay=args.fsm_stream_giveup_delay, + stream_end_delay=args.fsm_stream_end_delay, + tts_end_delay=args.fsm_tts_end_delay, + followup_vad_refractory=args.fsm_followup_vad_refractory, + listen_start_alert_wav=args.fsm_listen_start_wav, + listen_stop_alert_wav=args.fsm_listen_stop_wav, + ), debug_recording_dir=args.debug_recording_dir, ) satellite: SatelliteBase - if settings.wake.enabled: + if settings.wake.enabled and settings.vad.enabled: + satellite = FSMSatellite(settings) + elif settings.wake.enabled: # Local wake word detection satellite = WakeStreamingSatellite(settings) elif settings.vad.enabled: diff --git a/wyoming_satellite/fsmsat.py b/wyoming_satellite/fsmsat.py new file mode 100644 index 0000000..8cf1a84 --- /dev/null +++ b/wyoming_satellite/fsmsat.py @@ -0,0 +1,558 @@ +from enum import Enum, StrEnum, auto +import sys +import math +import logging +import time +import asyncio +from typing import ( + Any, + Awaitable, + Callable, + ClassVar, + Coroutine, + Final, + List, + Mapping, + Optional, +) +import queue + +from pyring_buffer import RingBuffer + + +from wyoming.client import AsyncClient +from wyoming.info import Describe, Info +from wyoming.asr import Transcript +from wyoming.audio import AudioChunk, AudioStop, AudioStart, AudioFormat +from wyoming.error import Error +from wyoming.event import Event +from wyoming.satellite import ( + PauseSatellite, + RunSatellite, +) +from wyoming.wake import Detection +from wyoming.snd import SndProcessAsyncClient + +from .utils.audio import wav_to_events + +from .satellite import SatelliteBase +from .settings import SatelliteSettings +from .vad import SileroVad + + +_LOGGER = logging.getLogger() +_WAKE_INFO_TIMEOUT: Final = 2 + + +class SatState(Enum): + """Nodes in the finite state machine graph""" + + PAUSED = auto() + """The satellite is not listening. It is waiting for a connection or a start command from a server.""" + + MONITOR = auto() + """The satellite is listening, and forwarding audio to the wakeword service. It is waiting for a wakeword detection.""" + + STREAM_TO_SERVER = auto() + """The satellite is streaming mic audio to the server.""" + + PLAYBACK = auto() + """The satellite is playing audio from the server. In this state, the satellite does not process mic input.""" + + FOLLOWUP = auto() + """The satellite recently finished playback, and we are now waiting to see if the user responds. In this state, a wakeword is not required; the satellite will start streaming audio to the server again as soon as the VAD triggers. """ + + +class SatEvent(StrEnum): + """Events which affect the state of the satellite.""" + + VAD = "vad" + """The voice activation detection service triggered.""" + + WAKEWORD = "wakeword" + """The wakeword detection service triggered.""" + + STATE_CHANGE = "state_change" + """The satellite changed states.""" + + TTS_START = "tts_start" + """The satellite started playing audio from the server.""" + + TTS_END = "tts_end" + """The satellite stopped playing audio from the server.""" + + SERVER_DISCONNECT = "server_disconnect" + """The server disconnected.""" + + SERVER_CONNECT = "server_connect" + """The server connected.""" + + PAUSE = "pause" + """The server instructed the satellite to pause.""" + + +StateChangeListener = Callable[ + ["FSMSatellite", SatState, SatState], Coroutine[Any, Any, None] +] +StateChangePredicate = Callable[[SatState, SatState], bool] + + +_state_change_listeners: list[tuple[StateChangePredicate, StateChangeListener]] = [] + + +def on_state_change( + old_state: Optional[SatState] = None, + new_state: Optional[SatState] = None, +) -> Callable[[StateChangeListener], StateChangeListener]: + """ + Decorator that registers a method as a state change listener. The method will be + invoked when the satellite changes states. If old_state is provided, then the + listener only triggers when the satellite is exiting the given state. Likewise, + if new_state is provided, the listener triggers only when the satellite is + entering new_state. Valid on instance methods of FSMSatellite. + """ + + def state_change_decorator(func: StateChangeListener) -> StateChangeListener: + key = old_state, new_state + + def predicate(event_old_state: SatState, event_new_state: SatState) -> bool: + if old_state is not None and old_state != event_old_state: + return False + if new_state is not None and new_state != event_new_state: + return False + return True + + _state_change_listeners.append((predicate, func)) + return func + + return state_change_decorator + + +class FSMSatellite(SatelliteBase): + """ + A satellite that uses both wakeword and VAD to support back-and-forth conversation. + A wakeword trigger is required for the first user speech, but for a short time + after TTS plays, a wakeword is not required to stream a follow-up prompt. + + See SatState enum docs for full state machine details. + """ + + def __init__(self, settings: SatelliteSettings) -> None: + super().__init__(settings) + self.vad = SileroVad( + threshold=settings.vad.threshold, trigger_level=settings.vad.trigger_level + ) + self.mic_format: AudioFormat = AudioFormat( + settings.mic.rate, settings.mic.width, settings.mic.channels + ) + + self.sat_events: dict[SatEvent, float] = {} + self.sat_state: SatState = SatState.PAUSED + self.wav_tasks: List[asyncio.Task] = [] + + # Audio from right before speech starts (circular buffer) + self.vad_buffer: Optional[RingBuffer] = None + self.vad_chunk: Optional[AudioChunk] = None + self.last_debug: float = time.monotonic() + # track a heuristic for when we expect the TTS audio from the server to stop playing. + # this is only a best-effort guess. + self.expected_playback_end: float = time.monotonic() + + self._wake_info: Optional[Info] = None + self._wake_info_ready = asyncio.Event() + self._debug_recording_timestamp: Optional[int] = None + + if settings.vad.buffer_seconds > 0: + vad_buffer_bytes = int( + math.ceil( + settings.vad.buffer_seconds + * self.mic_format.rate + * self.mic_format.width + * self.mic_format.channels + ) + ) + self.vad_buffer = RingBuffer(maxlen=vad_buffer_bytes) + + async def run(self) -> None: + async with asyncio.TaskGroup() as tg: + tg.create_task(super().run()) + tg.create_task(self.background_tasks()) + + async def background_tasks(self) -> None: + """Asynchronously read from stdin. When a line is read, print debug info on the current state.""" + lines: queue.Queue[str] = queue.Queue() + + def read_stdin() -> str: + return sys.stdin.readline() + + while True: + line = await asyncio.to_thread(read_stdin) + t_since = {e.name: self.time_since_last_event(e) for e in self.sat_events} + _LOGGER.debug(f"current status: {self.sat_state} {t_since}") + + def mark_sat_event(self, event: SatEvent) -> None: + """Record the time when an event occurs, overwriting previous occurrences.""" + self.sat_events[event] = time.monotonic() + + def times_since_events(self) -> Mapping[SatEvent, float | None]: + """ + Return a mapping from all sat events that happened since the + last state change, to the number of seconds since that event occurred. + """ + now = time.monotonic() + return {e: self.time_since_last_event(e, now) for e in self.sat_events} + + def set_sat_state(self, new_state: SatState) -> None: + """ + Update the satellite state, reseting all sat events. + """ + old_state = self.sat_state + if old_state == new_state: + _LOGGER.warning( + f"Tried to change state, but we were already in the requested state {new_state}. Ignoring." + ) + return + _LOGGER.debug( + f"exiting state: {old_state} events: {self.times_since_events()}" + ) + _LOGGER.info(f"entering state: {new_state}") + self.sat_state = new_state + self.sat_events = {} + self.mark_sat_event(SatEvent.STATE_CHANGE) + + async def play_wav_background(self, wav_path: str) -> None: + """ + Play a wav file asynchronously. + """ + + async def background_task() -> None: + client = self._make_snd_client() + assert client is not None + await client.connect() + try: + for event in wav_to_events( + wav_path, + samples_per_chunk=self.settings.snd.samples_per_chunk, + ): + await client.write_event(event) + finally: + await client.disconnect() + + i: int = 0 + while i < len(self.wav_tasks): + task = self.wav_tasks[i] + if task.done(): + await self.wav_tasks.pop(i) + else: + i += 1 + self.wav_tasks.append(asyncio.create_task(background_task())) + + async def event_from_server(self, event: Event) -> None: + await super().event_from_server(event) + + if RunSatellite.is_type(event.type): + _LOGGER.info("server requested satellite start; starting") + if self.sat_state == SatState.PAUSED: + self.set_sat_state(SatState.MONITOR) + 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) + or PauseSatellite.is_type(event.type) + ): + if PauseSatellite.is_type(event.type): + self.mark_sat_event(SatEvent.PAUSE) + # Stop debug recording + if self.stt_audio_writer is not None: + self.stt_audio_writer.stop() + + 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 + or self.sat_state == SatState.PAUSED + ): + return + chunk = AudioChunk.from_event(event) + audio_bytes = chunk.audio + now = time.monotonic() + if now - self.last_debug > 1: + self.last_debug = now + + if self.stt_audio_writer is not None and audio_bytes is not None: + self.stt_audio_writer.write(audio_bytes) + + voice_detected = self.vad(audio_bytes) + if voice_detected: + self.mark_sat_event(SatEvent.VAD) + + new_state = await self.update_state() + + await self.event_to_wake(event) + if new_state == SatState.STREAM_TO_SERVER: + await self.event_to_server(event) + if new_state in (SatState.MONITOR, SatState.FOLLOWUP): + if self.vad_buffer is not None: + self.vad_chunk = chunk + self.vad_buffer.put(audio_bytes) + + async def update_state(self) -> SatState: + """ + Check if a state transition should happen. If so, update our state + and trigger appropriate listeners. + + :returns: The current satellite state, which may or may not differ + from the old state. + """ + old_state = self.sat_state + new_state = self.get_next_state() + if old_state != new_state: + self.set_sat_state(new_state) + await self.handle_state_change(old_state, new_state) + return new_state + + @on_state_change(new_state=SatState.STREAM_TO_SERVER) + async def on_stream_start(self, old_state: SatState, new_state: SatState) -> None: + """ + When we are about to start streaming audio to the server, send the server + audio and pipeline start events, then send any buffered pre-VAD audio + """ + await self.event_to_server( + AudioStart( + rate=self.mic_format.rate, + width=self.mic_format.width, + channels=self.mic_format.channels, + ).event() + ) + await self._send_run_pipeline() + await self.drain_vad_buffer() + + @on_state_change(new_state=SatState.STREAM_TO_SERVER) + @on_state_change(new_state=SatState.FOLLOWUP) + async def on_listen_start(self, old_state: SatState, new_state: SatState) -> None: + """ + When we begin streaming or start listening for a followup response, + play an alert sound to indicate to the user that we're listening. + """ + if self.settings.fsm.listen_start_alert_wav: + await self.play_wav_background(self.settings.fsm.listen_start_alert_wav) + if self.settings.fsm.listen_stop_alert_wav: + await self.play_wav_background(self.settings.fsm.listen_stop_alert_wav) + + @on_state_change(old_state=SatState.STREAM_TO_SERVER) + async def on_stream_stop(self, old_state: SatState, new_state: SatState) -> None: + """ + When we stop streaming audio to the server, play an alert sound + to indicate to the user that we're no longer listening and send + and audio stop event to the server. + """ + if self.settings.fsm.listen_stop_alert_wav: + await self.play_wav_background(self.settings.fsm.listen_stop_alert_wav) + await self.event_to_server(AudioStop().event()) + + async def handle_state_change( + self, old_state: SatState, new_state: SatState + ) -> None: + """ + When the satellite transitions from old_state to new_state, + trigger all state change listeners that apply. + """ + async with asyncio.TaskGroup() as tg: + for predicate, handler in _state_change_listeners: + if predicate(old_state, new_state): + tg.create_task(handler(self, old_state, new_state)) + + async def drain_vad_buffer(self) -> None: + """Send the user speech that was buffered before VAD to the server, and reset the VAD buffer.""" + if self.vad_buffer is not None and self.vad_chunk is not None: + chunk = self.vad_chunk + await self.event_to_server( + AudioChunk( + rate=chunk.rate, + width=chunk.width, + channels=chunk.channels, + audio=self.vad_buffer.getvalue(), + ).event() + ) + self._reset_vad() + + def time_since_last_event( + self, event_type: SatEvent, now: Optional[float] = None + ) -> Optional[float]: + """Return the time the event was last marked, in seconds""" + if now is None: + now = time.monotonic() + event_ts = self.sat_events.get(event_type) + if event_ts is None: + return None + return now - event_ts + + def get_next_state(self) -> SatState: + """ + Check our current state and past events to determine what the next state should be. + The return value is either the current state, or a new state. + """ + last_state_change = self.time_since_last_event(SatEvent.STATE_CHANGE) + assert last_state_change is not None, "State change event should always exist" + last_vad = self.time_since_last_event(SatEvent.VAD) + + if self.time_since_last_event(SatEvent.SERVER_DISCONNECT) is not None: + return SatState.PAUSED + elif self.sat_state == SatState.PAUSED: + if self.time_since_last_event(SatEvent.SERVER_CONNECT) is not None: + return SatState.MONITOR + elif self.sat_state == SatState.MONITOR: + last_wakeword = self.time_since_last_event(SatEvent.WAKEWORD) + if last_wakeword is not None: + return SatState.STREAM_TO_SERVER + elif self.sat_state == SatState.STREAM_TO_SERVER: + if ( + last_vad is None + and last_state_change > self.settings.fsm.stream_giveup_delay + ): + return SatState.PLAYBACK + elif last_vad is not None and last_vad > self.settings.fsm.stream_end_delay: + return SatState.PLAYBACK + elif self.sat_state == SatState.PLAYBACK: + tts_end = self.time_since_last_event(SatEvent.TTS_END) + if self.time_since_last_event(SatEvent.PAUSE) is not None: + return SatState.MONITOR + elif tts_end is not None and tts_end > self.settings.fsm.tts_end_delay: + return SatState.FOLLOWUP + elif self.sat_state == SatState.FOLLOWUP: + if self.time_since_last_event(SatEvent.PAUSE) is not None: + return SatState.MONITOR + elif ( + last_vad is not None + and last_state_change - last_vad + > self.settings.fsm.followup_vad_refractory + ): + return SatState.STREAM_TO_SERVER + elif last_state_change > self.settings.fsm.followup_timeout: + return SatState.MONITOR + + # no state change, return the current state + return self.sat_state + + async def trigger_server_disonnected(self) -> None: + await super().trigger_server_disonnected() + self.mark_sat_event(SatEvent.SERVER_DISCONNECT) + + async def trigger_server_connected(self) -> None: + await super().trigger_server_connected() + self.mark_sat_event(SatEvent.SERVER_CONNECT) + + async def _snd_task_proc(self) -> None: + """Snd service loop.""" + snd_client: Optional[AsyncClient] = None + + async def _disconnect() -> None: + try: + if snd_client is not None: + await snd_client.disconnect() + except Exception: + pass # ignore disconnect errors + + while self.is_running: + try: + if self._snd_queue is None: + self._snd_queue = asyncio.Queue() + + snd_event = await self._snd_queue.get() + event = snd_event.event + + if snd_client is None: + snd_client = self._make_snd_client() + assert snd_client is not None + await snd_client.connect() + _LOGGER.debug("Connected to snd service") + + if AudioChunk.is_type(event.type): + chunk = AudioChunk.from_event(event) + + if SatEvent.TTS_START not in self.sat_events: + self.mark_sat_event(SatEvent.TTS_START) + # Audio processing + if self.settings.snd.needs_processing: + audio_bytes = self._process_snd_audio(chunk.audio) + event = AudioChunk( + rate=chunk.rate, + width=chunk.width, + channels=chunk.channels, + audio=audio_bytes, + ).event() + + self.expected_playback_end = ( + max(time.monotonic(), self.expected_playback_end) + + chunk.seconds + ) + await snd_client.write_event(event) + + if self.settings.snd.disconnect_after_stop and AudioStop.is_type( + event.type + ): + if isinstance(snd_client, SndProcessAsyncClient): + self.sat_events[SatEvent.TTS_END] = self.expected_playback_end + await _disconnect() + if snd_event.is_tts: + await self.trigger_played() + snd_client = None # reconnect on next event + except asyncio.CancelledError: + break + except Exception: + _LOGGER.exception("Unexpected error in snd read task") + await _disconnect() + snd_client = None # reconnect + self._snd_queue = None + await asyncio.sleep(self.settings.snd.reconnect_seconds) + + await _disconnect() + + def _reset_vad(self): + """Reset state of VAD.""" + self.vad(None) + if self.vad_buffer is not None: + self.vad_buffer.put(bytes(self.vad_buffer.maxlen)) + self.vad_chunk = None + + 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 Detection.is_type(event.type): + detection = Detection.from_event(event) + # Stop debug recording (wake) + if self.wake_audio_writer is not None: + self.wake_audio_writer.stop() + # Start debug recording (stt) + if self.stt_audio_writer is not None: + self.stt_audio_writer.start(timestamp=self._debug_recording_timestamp) + + _LOGGER.info("wakeword detected") + self.mark_sat_event(SatEvent.WAKEWORD) + + await self.event_to_server(event) + await self.forward_event(event) # forward to event service + + 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") diff --git a/wyoming_satellite/settings.py b/wyoming_satellite/settings.py index fd9c959..b8139aa 100644 --- a/wyoming_satellite/settings.py +++ b/wyoming_satellite/settings.py @@ -132,6 +132,30 @@ class WakeSettings(ServiceSettings): """Seconds after a wake word detection before another detection is handled.""" +@dataclass(frozen=True) +class FSMSettings: + + stream_giveup_delay: float = 5 + """Time in seconds after which we should give up and cancel streaming audio to the server if no vad has triggered since we started streaming.""" + + stream_end_delay: float = 1.2 + """Seconds of continuos time to require with no VAD trigger before determining that user speech has ended and closing a stream. This only applies if VAD has triggered at least once since the start of the stream; othrewise, see stream_giveup_delay.""" + + tts_end_delay: float = 0.5 + """Seconds to wait after TTS playback stops before we start listening for user speech again.""" + + followup_vad_refractory: float = 1.5 + """Seconds to wait after entering a followup state before VAD triggers are honored. This delay helps prevent false postitive VAD triggers that might otherwise happen due to TTS feedback.""" + + followup_timeout: float = 10 + """During followup, seconds to wait for VAD before returning to monitor state (i.e., requiring wakeword again)""" + + listen_start_alert_wav: Optional[str] = "sounds/awake-quiet.wav" + """Wav file to play when the satellite detects the beginning of user speech, which means that either a wakeword detection occurs during monitor state, or VAD occurs during followup state. File plays using snd service.""" + listen_stop_alert_wav: Optional[str] = "sounds/done-quiet.wav" + """Wav file to play when the satellite detects the end of user speech.""" + + @dataclass(frozen=True) class VadSettings: """Voice activity detector settings.""" @@ -209,6 +233,7 @@ class SatelliteSettings: snd: SndSettings = field(default_factory=SndSettings) event: EventSettings = field(default_factory=EventSettings) timer: TimerSettings = field(default_factory=TimerSettings) + fsm: FSMSettings = field(default_factory=FSMSettings) restart_timeout: float = 5.0