44``wakeword_detection`` (registered in the backend's ``_EXPECTED_GROUPS`` so the
55streaming consumer won't delete streams out from under our pending entries).
66
7- For each client stream it maintains per-client wake state in
7+ For each session stream it maintains per-session wake state in
88:class:`HermesDetector`. On a captured turn it resolves the command text from
99the existing ``transcription:results:{session_id}`` stream (no second ASR) and
1010publishes a ``wake_word.detected`` message to the ``wakeword:detections`` stream
2121from typing import Dict
2222
2323import redis .asyncio as redis
24+ from redis import exceptions as redis_exceptions
25+
2426from detector import (
2527 RECEPTIVE_FIELD_SECONDS ,
2628 SAMPLE_RATE ,
2729 ClientWakeState ,
2830 HermesDetector ,
2931 WakeEvent ,
3032)
31- from redis import exceptions as redis_exceptions
3233from samples import PENDING , SampleStore
3334
3435logger = logging .getLogger (__name__ )
@@ -75,9 +76,7 @@ def _load_tones() -> Dict[str, str]:
7576class WakeWordConsumer :
7677 """Discovers audio streams and runs acoustic wake detection on each."""
7778
78- def __init__ (
79- self , detector : HermesDetector , redis_url : str , sample_store : SampleStore
80- ):
79+ def __init__ (self , detector : HermesDetector , redis_url : str , sample_store : SampleStore ):
8180 """Initialize the consumer.
8281
8382 Args:
@@ -91,25 +90,29 @@ def __init__(
9190 self .redis_client : redis .Redis | None = None
9291 self .consumer_name = f"wakeword-worker-{ os .getpid ()} "
9392 self .running = False
94- # client_id -> asyncio.Task processing that stream
93+ # session_id -> asyncio.Task processing that stream
9594 self ._stream_tasks : Dict [str , asyncio .Task ] = {}
96- # client_id -> live wake state, so HTTP handlers can prime a stream.
95+ # session_id -> live wake state, so HTTP handlers can prime a stream.
9796 self ._states : Dict [str , ClientWakeState ] = {}
97+ # session_id -> stable device client_id, resolved from session metadata.
98+ self ._client_ids : Dict [str , str ] = {}
9899
99100 def active_clients (self ) -> list [dict ]:
100101 """List currently-processing streams (for the data-collection UI)."""
101102 out = []
102- for client_id , task in self ._stream_tasks .items ():
103+ for session_id , task in self ._stream_tasks .items ():
103104 if task .done ():
104105 continue
105- state = self ._states .get (client_id )
106+ client_id = self ._client_ids .get (session_id )
107+ state = self ._states .get (session_id )
108+ if client_id is None or state is None :
109+ continue
106110 out .append (
107111 {
108112 "client_id" : client_id ,
113+ "session_id" : session_id ,
109114 "priming" : bool (state and state .priming ),
110- "prime_wakeword" : (
111- state .prime_wakeword if state and state .priming else None
112- ),
115+ "prime_wakeword" : (state .prime_wakeword if state and state .priming else None ),
113116 "armed" : bool (state and state .armed ),
114117 }
115118 )
@@ -121,33 +124,39 @@ def prime(self, client_id: str, wakeword: str) -> bool:
121124 Returns False if the stream is unknown. Raises ValueError if ``wakeword``
122125 is not a configured wake word.
123126 """
124- state = self ._states .get (client_id )
125- task = self ._stream_tasks .get (client_id )
126- if state is None or task is None or task .done ():
127- return False
128- self .detector .start_priming (state , client_id , wakeword )
129- return True
127+ # A reconnect can briefly leave an older session draining. Walk newest
128+ # first so the command targets the device's current live stream.
129+ for session_id in reversed (self ._stream_tasks ):
130+ if self ._client_ids .get (session_id ) != client_id :
131+ continue
132+ state = self ._states .get (session_id )
133+ task = self ._stream_tasks .get (session_id )
134+ if state is not None and task is not None and not task .done ():
135+ self .detector .start_priming (state , client_id , wakeword )
136+ return True
137+ return False
130138
131139 def unprime (self , client_id : str ) -> bool :
132140 """Manually end an in-progress prime capture (UI 'stop'). False if unknown.
133141
134142 The per-stream task finalizes and saves on its next frame, so the captured
135143 attempt always lands in the review queue rather than being dropped.
136144 """
137- state = self ._states .get (client_id )
138- task = self ._stream_tasks .get (client_id )
139- if state is None or task is None or task .done ():
140- return False
141- self .detector .stop_priming (state )
142- return True
145+ for session_id in reversed (self ._stream_tasks ):
146+ if self ._client_ids .get (session_id ) != client_id :
147+ continue
148+ state = self ._states .get (session_id )
149+ task = self ._stream_tasks .get (session_id )
150+ if state is not None and task is not None and not task .done ():
151+ self .detector .stop_priming (state )
152+ return True
153+ return False
143154
144155 async def start (self ) -> None :
145156 """Connect to Redis and run the discovery + processing loop."""
146157 self .redis_client = redis .from_url (self .redis_url )
147158 self .running = True
148- logger .info (
149- f"WakeWordConsumer started (group={ GROUP_NAME } , redis={ self .redis_url } )"
150- )
159+ logger .info (f"WakeWordConsumer started (group={ GROUP_NAME } , redis={ self .redis_url } )" )
151160 try :
152161 while self .running :
153162 # A transient Redis failure (e.g. Redis restarting during a stack
@@ -161,9 +170,7 @@ async def start(self) -> None:
161170 except asyncio .CancelledError :
162171 raise
163172 except redis_exceptions .RedisError as e :
164- logger .warning (
165- f"Redis error in discovery loop (retrying in 2s): { e } "
166- )
173+ logger .warning (f"Redis error in discovery loop (retrying in 2s): { e } " )
167174 await asyncio .sleep (2.0 )
168175 except Exception as e : # noqa: BLE001 - loop must never die silently
169176 logger .error (
@@ -180,36 +187,38 @@ async def stop(self) -> None:
180187
181188 async def _discover_and_spawn (self ) -> None :
182189 streams = await self ._discover_streams ()
183- live_clients : set [str ] = set ()
190+ live_sessions : set [str ] = set ()
184191 for stream_name in streams :
185- client_id = stream_name .replace ("audio:stream:" , " " )
192+ session_id = stream_name .removeprefix ("audio:stream:" )
186193 # A device that drops without a clean end-marker leaves its
187194 # audio:stream key behind (session stuck "active"). Without this
188195 # check we'd re-spawn a task for that dead key every time the
189196 # previous one idled out, so it perpetually shows as an "active
190197 # stream". Only process streams that got a chunk recently.
191198 if not await self ._stream_is_live (stream_name ):
192199 continue
193- live_clients .add (client_id )
194- task = self ._stream_tasks .get (client_id )
200+ live_sessions .add (session_id )
201+ task = self ._stream_tasks .get (session_id )
195202 if task is None or task .done ():
196203 if task is not None and task .done ():
197204 # Surface any exception from the finished task.
198205 exc = task .exception ()
199206 if exc is not None :
200- logger .error (f"Stream task for '{ client_id } ' failed: { exc } " )
201- self ._stream_tasks [client_id ] = asyncio .create_task (
202- self ._process_stream (stream_name , client_id )
207+ logger .error (f"Stream task for '{ session_id } ' failed: { exc } " )
208+ self ._stream_tasks [session_id ] = asyncio .create_task (
209+ self ._process_stream (stream_name , session_id )
203210 )
204211 # Reap tasks whose stream is gone or has gone stale, so they stop being
205212 # reported as active streams (and free their per-client detector state).
206- for client_id , task in list (self ._stream_tasks .items ()):
213+ for session_id , task in list (self ._stream_tasks .items ()):
207214 if task .done ():
208- self ._stream_tasks .pop (client_id , None )
209- elif client_id not in live_clients :
210- logger .info (f"Reaping wake stream task for stale '{ client_id } '" )
215+ self ._stream_tasks .pop (session_id , None )
216+ self ._client_ids .pop (session_id , None )
217+ elif session_id not in live_sessions :
218+ logger .info (f"Reaping wake stream task for stale '{ session_id } '" )
211219 task .cancel ()
212- self ._stream_tasks .pop (client_id , None )
220+ self ._stream_tasks .pop (session_id , None )
221+ self ._client_ids .pop (session_id , None )
213222
214223 async def _stream_is_live (self , stream_name : str ) -> bool :
215224 """True if the stream received a chunk within the idle window.
@@ -237,29 +246,26 @@ async def _discover_streams(self) -> list[str]:
237246 streams : list [str ] = []
238247 cursor = b"0"
239248 while cursor :
240- cursor , keys = await self .redis_client .scan (
241- cursor , match = STREAM_PATTERN , count = 100
242- )
249+ cursor , keys = await self .redis_client .scan (cursor , match = STREAM_PATTERN , count = 100 )
243250 streams .extend (k .decode () if isinstance (k , bytes ) else k for k in keys )
244251 return streams
245252
246253 async def _setup_group (self , stream_name : str ) -> None :
247254 try :
248- await self .redis_client .xgroup_create (
249- stream_name , GROUP_NAME , "0" , mkstream = True
250- )
255+ await self .redis_client .xgroup_create (stream_name , GROUP_NAME , "0" , mkstream = True )
251256 logger .debug (f"Created group { GROUP_NAME } for { stream_name } " )
252257 except redis_exceptions .ResponseError as e :
253258 if "BUSYGROUP" not in str (e ):
254259 raise
255260
256- async def _process_stream (self , stream_name : str , client_id : str ) -> None :
261+ async def _process_stream (self , stream_name : str , session_id : str ) -> None :
257262 await self ._setup_group (stream_name )
263+ client_id = await self ._lookup_client_id (session_id )
258264 state = self .detector .new_client_state ()
259- self ._states [client_id ] = state
260- session_id = client_id # session_id == client_id in this pipeline
265+ self ._states [session_id ] = state
266+ self . _client_ids [ session_id ] = client_id
261267 last_activity = time .time ()
262- logger .info (f"▶ Processing wake stream '{ stream_name } '" )
268+ logger .info (f"▶ Processing wake stream '{ stream_name } ' for client ' { client_id } ' " )
263269
264270 try :
265271 while self .running :
@@ -274,24 +280,18 @@ async def _process_stream(self, stream_name: str, client_id: str) -> None:
274280 if not messages :
275281 if time .time () - last_activity > STREAM_IDLE_TIMEOUT_SECONDS :
276282 await self ._flush (state , client_id , session_id )
277- logger .info (
278- f"Stream '{ stream_name } ' idle — ending wake processing"
279- )
283+ logger .info (f"Stream '{ stream_name } ' idle — ending wake processing" )
280284 return
281285 continue
282286
283287 for _stream , stream_messages in messages :
284288 for message_id , fields in stream_messages :
285289 msg_id = (
286- message_id .decode ()
287- if isinstance (message_id , bytes )
288- else message_id
290+ message_id .decode () if isinstance (message_id , bytes ) else message_id
289291 )
290292 try :
291293 if fields .get (b"end_marker" ) or fields .get ("end_marker" ):
292- await self .redis_client .xack (
293- stream_name , GROUP_NAME , msg_id
294- )
294+ await self .redis_client .xack (stream_name , GROUP_NAME , msg_id )
295295 await self ._flush (state , client_id , session_id )
296296 logger .info (f"End marker on '{ stream_name } ' — ending" )
297297 return
@@ -310,11 +310,10 @@ async def _process_stream(self, stream_name: str, client_id: str) -> None:
310310 if event is not None :
311311 await self ._handle_event (event )
312312 finally :
313- await self .redis_client .xack (
314- stream_name , GROUP_NAME , msg_id
315- )
313+ await self .redis_client .xack (stream_name , GROUP_NAME , msg_id )
316314 finally :
317- self ._states .pop (client_id , None )
315+ self ._states .pop (session_id , None )
316+ self ._client_ids .pop (session_id , None )
318317
319318 async def _flush (self , state , client_id : str , session_id : str ) -> None :
320319 """Finalize an armed-but-uncaptured turn when the stream ends/goes idle."""
@@ -397,9 +396,7 @@ def _save_sample(self, bucket: str, event: WakeEvent, pcm: bytes) -> None:
397396 f"💾 saved { bucket } sample { rec ['id' ]} ({ len (pcm )} B"
398397 f"{ ', +buffer-state' if rec .get ('has_buffer_state' ) else '' } )"
399398 )
400- except (
401- Exception
402- ) as e : # noqa: BLE001 - data collection must never break dispatch
399+ except Exception as e : # noqa: BLE001 - data collection must never break dispatch
403400 logger .error (f"Failed to save { bucket } sample: { e } " , exc_info = True )
404401
405402 async def _publish_detection (self , event : WakeEvent ) -> None :
@@ -463,9 +460,7 @@ async def _publish_detection(self, event: WakeEvent) -> None:
463460 f"({ len (event .audio )} B audio, reason={ event .reason } )"
464461 )
465462
466- async def _on_armed (
467- self , state : ClientWakeState , client_id : str , session_id : str
468- ) -> None :
463+ async def _on_armed (self , state : ClientWakeState , client_id : str , session_id : str ) -> None :
469464 """Push a UI pulse the instant the wake word arms (before capture/ASR)."""
470465 # Listening tone FIRST — a pure ack needing only client_id, so it never waits
471466 # behind the user lookup / SSE below (keeps the cue instant under load).
@@ -512,9 +507,7 @@ async def _send_tone(self, client_id: str, tone: str) -> None:
512507 {"audio_b64" : audio_b64 , "format" : "wav" , "announcement" : True },
513508 )
514509
515- async def _publish_downlink (
516- self , client_id : str , msg_type : str , data : dict
517- ) -> None :
510+ async def _publish_downlink (self , client_id : str , msg_type : str , data : dict ) -> None :
518511 """Push a control message to the device via ``device:downlink:{client_id}``.
519512
520513 The backend's WebSocket handler subscribes to this channel and forwards the
@@ -526,9 +519,7 @@ async def _publish_downlink(
526519 try :
527520 message = json .dumps ({"type" : msg_type , "data" : data })
528521 await self .redis_client .publish (f"device:downlink:{ client_id } " , message )
529- except (
530- Exception
531- ) as e : # noqa: BLE001 - downlink is best-effort, never break dispatch
522+ except Exception as e : # noqa: BLE001 - downlink is best-effort, never break dispatch
532523 logger .debug (f"Failed to publish downlink { msg_type } : { e } " )
533524
534525 async def _publish_sse (self , user_id : str , event_type : str , data : dict ) -> None :
@@ -540,13 +531,9 @@ async def _publish_sse(self, user_id: str, event_type: str, data: dict) -> None:
540531 if not user_id :
541532 return
542533 try :
543- message = json .dumps (
544- {"event" : event_type , "data" : data , "timestamp" : time .time ()}
545- )
534+ message = json .dumps ({"event" : event_type , "data" : data , "timestamp" : time .time ()})
546535 await self .redis_client .publish (f"sse:{ user_id } " , message )
547- except (
548- Exception
549- ) as e : # noqa: BLE001 - SSE is best-effort, never break dispatch
536+ except Exception as e : # noqa: BLE001 - SSE is best-effort, never break dispatch
550537 logger .debug (f"Failed to publish SSE { event_type } : { e } " )
551538
552539 async def _lookup_user_id (self , session_id : str ) -> str :
@@ -559,9 +546,25 @@ async def _lookup_user_id(self, session_id: str) -> str:
559546 logger .warning (f"Could not read user_id for { session_id } : { e } " )
560547 return ""
561548
549+ async def _lookup_client_id (self , session_id : str ) -> str :
550+ """Resolve the stable device id from authoritative session metadata."""
551+ if self .redis_client is None :
552+ raise RuntimeError ("Redis is not connected" )
553+ value = await self .redis_client .hget (f"audio:session:{ session_id } " , "client_id" )
554+ if value is None :
555+ raise RuntimeError (f"Audio session '{ session_id } ' has no client_id" )
556+ client_id = value .decode () if isinstance (value , bytes ) else str (value )
557+ client_id = client_id .strip ()
558+ if not client_id :
559+ raise RuntimeError (f"Audio session '{ session_id } ' has an empty client_id" )
560+ return client_id
561+
562562 async def _shutdown (self ) -> None :
563563 for task in self ._stream_tasks .values ():
564564 task .cancel ()
565+ self ._stream_tasks .clear ()
566+ self ._states .clear ()
567+ self ._client_ids .clear ()
565568 if self .redis_client is not None :
566569 await self .redis_client .aclose ()
567570 logger .info ("WakeWordConsumer stopped" )
0 commit comments