Skip to content

Commit 8f050b5

Browse files
committed
fix(wakeword): route replies by device client id
1 parent 1cdcaac commit 8f050b5

3 files changed

Lines changed: 204 additions & 174 deletions

File tree

extras/wakeword-service/consumer.py

Lines changed: 86 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
``wakeword_detection`` (registered in the backend's ``_EXPECTED_GROUPS`` so the
55
streaming 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
99
the existing ``transcription:results:{session_id}`` stream (no second ASR) and
1010
publishes a ``wake_word.detected`` message to the ``wakeword:detections`` stream
@@ -21,14 +21,15 @@
2121
from typing import Dict
2222

2323
import redis.asyncio as redis
24+
from redis import exceptions as redis_exceptions
25+
2426
from detector import (
2527
RECEPTIVE_FIELD_SECONDS,
2628
SAMPLE_RATE,
2729
ClientWakeState,
2830
HermesDetector,
2931
WakeEvent,
3032
)
31-
from redis import exceptions as redis_exceptions
3233
from samples import PENDING, SampleStore
3334

3435
logger = logging.getLogger(__name__)
@@ -75,9 +76,7 @@ def _load_tones() -> Dict[str, str]:
7576
class 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

Comments
 (0)