infinicode 2.8.35 → 2.8.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,10 +45,14 @@ from __future__ import annotations
45
45
 
46
46
  import argparse
47
47
  import asyncio
48
+ import glob
48
49
  import json
49
50
  import logging
51
+ import math
50
52
  import os
51
53
  import signal
54
+ import socket
55
+ import struct
52
56
  import sys
53
57
  import threading
54
58
  import time
@@ -62,6 +66,130 @@ import httpx
62
66
 
63
67
  logger = logging.getLogger("robopark.preview_agent")
64
68
 
69
+ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
+
71
+
72
+ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
73
+ """Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
74
+ if not url:
75
+ return url
76
+ for scheme in ("ws", "wss"):
77
+ prefix = f"{scheme}://localhost"
78
+ if url.startswith(prefix):
79
+ return f"{scheme}://127.0.0.1" + url[len(prefix):]
80
+ return url
81
+
82
+
83
+ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
84
+ """Play a short local cue without involving the voice pipeline."""
85
+ try:
86
+ import pyaudio
87
+ except Exception:
88
+ return
89
+ sample_rate = 48000
90
+ channels = 2
91
+ if effect == "motion":
92
+ notes = ((880, 0.09), (1320, 0.13))
93
+ else:
94
+ notes = ((660, 0.10), (440, 0.16))
95
+ pa = pyaudio.PyAudio()
96
+ device_index = None
97
+ selected = str(selected_output or "default")
98
+ try:
99
+ if selected.strip().isdigit():
100
+ device_index = int(selected.strip())
101
+ elif selected.lower() == "default":
102
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
103
+ device_index = wasapi.get("defaultOutputDevice")
104
+ else:
105
+ needle = selected.lower()
106
+ for i in range(pa.get_device_count()):
107
+ info = pa.get_device_info_by_index(i)
108
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
109
+ device_index = i
110
+ break
111
+ stream = pa.open(
112
+ format=pyaudio.paInt16,
113
+ channels=channels,
114
+ rate=sample_rate,
115
+ output=True,
116
+ output_device_index=device_index,
117
+ )
118
+ for frequency, duration in notes:
119
+ count = int(sample_rate * duration)
120
+ frames = bytearray()
121
+ for n in range(count):
122
+ envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
123
+ value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
124
+ frames.extend(struct.pack("<hh", value, value))
125
+ stream.write(bytes(frames))
126
+ stream.stop_stream()
127
+ stream.close()
128
+ except Exception as e:
129
+ logger.debug(f"audio effect unavailable: {e}")
130
+ finally:
131
+ pa.terminate()
132
+
133
+
134
+ def _get_device_inventory() -> dict:
135
+ """Return discoverable camera and audio devices for dashboard selection."""
136
+ global _DEVICE_INVENTORY_CACHE
137
+ if _DEVICE_INVENTORY_CACHE is not None:
138
+ return _DEVICE_INVENTORY_CACHE
139
+
140
+ inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
141
+ inventory["video"].append({"id": "auto", "name": "Auto detect"})
142
+ inventory["video"].append({"id": "none", "name": "Disable camera"})
143
+
144
+ try:
145
+ import cv2
146
+ candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
147
+ for candidate in candidates:
148
+ value = int(candidate) if os.name == "nt" else candidate
149
+ backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
150
+ cap = cv2.VideoCapture(value, backend)
151
+ if cap.isOpened():
152
+ device_id = str(value)
153
+ inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
154
+ cap.release()
155
+ except Exception as e:
156
+ logger.debug(f"camera inventory unavailable: {e}")
157
+
158
+ try:
159
+ import pyaudio
160
+ pa = pyaudio.PyAudio()
161
+ default_in = None
162
+ default_out = None
163
+ try:
164
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
165
+ default_in = wasapi.get("defaultInputDevice")
166
+ default_out = wasapi.get("defaultOutputDevice")
167
+ except Exception:
168
+ pass
169
+ inventory["audio_input"].append({"id": "default", "name": "System default input"})
170
+ inventory["audio_output"].append({"id": "default", "name": "System default output"})
171
+ for i in range(pa.get_device_count()):
172
+ info = pa.get_device_info_by_index(i)
173
+ name = str(info.get("name", f"Audio device {i}"))
174
+ item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
175
+ if info.get("maxInputChannels", 0) > 0:
176
+ item["default"] = i == default_in
177
+ inventory["audio_input"].append(item.copy())
178
+ if info.get("maxOutputChannels", 0) > 0:
179
+ item["default"] = i == default_out
180
+ inventory["audio_output"].append(item.copy())
181
+ pa.terminate()
182
+ except Exception as e:
183
+ logger.debug(f"audio inventory unavailable: {e}")
184
+
185
+ _DEVICE_INVENTORY_CACHE = inventory
186
+ logger.info(
187
+ f"device inventory: {len(inventory['video']) - 2} cameras, "
188
+ f"{len(inventory['audio_input']) - 1} inputs, "
189
+ f"{len(inventory['audio_output']) - 1} outputs"
190
+ )
191
+ return inventory
192
+
65
193
  CONFIG_DIR = Path.home() / ".robopark"
66
194
  CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
67
195
  TOKEN_FILE = CONFIG_DIR / "device_token"
@@ -70,7 +198,7 @@ DEFAULT_VIDEO_WIDTH = 640
70
198
  DEFAULT_VIDEO_HEIGHT = 480
71
199
  DEFAULT_FPS = 15
72
200
  DEFAULT_POLL_INTERVAL = 3.0
73
- DEFAULT_HEARTBEAT_INTERVAL = 30.0
201
+ DEFAULT_HEARTBEAT_INTERVAL = 5.0
74
202
  DEFAULT_VISION_WEBHOOK_PORT = 5057
75
203
  DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
76
204
  DEFAULT_VISION_SESSION_SECONDS = 90.0
@@ -78,7 +206,10 @@ DEFAULT_VISION_SESSION_SECONDS = 90.0
78
206
  # (POST /api/sessions/{id}/keepalive, called by the voice agent — a
79
207
  # different repo, not this one) before preview_agent tears it down.
80
208
  # vision_session_seconds remains a hard ceiling regardless of activity.
81
- DEFAULT_VISION_SILENCE_TIMEOUT = 15.0
209
+ # Cloud STT/LLM/TTS can legitimately leave a session quiet for several
210
+ # seconds. The voice agent has its own VAD-aware 60s idle policy; this remote
211
+ # safety timeout must not preempt a turn while it is thinking or speaking.
212
+ DEFAULT_VISION_SILENCE_TIMEOUT = 60.0
82
213
 
83
214
 
84
215
  def _load_config() -> dict:
@@ -148,17 +279,20 @@ def _get_lan_ip() -> Optional[str]:
148
279
  return None
149
280
 
150
281
 
151
- async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> None:
282
+ async def _send_heartbeat(scheduler_url: str, device_id: str, token: str) -> Optional[bool]:
152
283
  try:
153
284
  async with httpx.AsyncClient() as client:
154
- await client.post(
285
+ response = await client.post(
155
286
  f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
156
- json={"status": "online", "ip": _get_lan_ip()},
287
+ json={"status": "online", "ip": _get_lan_ip(), "device_inventory": _get_device_inventory()},
157
288
  headers={"Authorization": f"Bearer {token}"},
158
289
  timeout=10.0,
159
290
  )
291
+ response.raise_for_status()
292
+ return bool(response.json().get("production_mode", False))
160
293
  except Exception as e:
161
294
  logger.debug(f"heartbeat failed: {e}")
295
+ return None
162
296
 
163
297
 
164
298
  @dataclass
@@ -167,6 +301,8 @@ class PreviewState:
167
301
  url: Optional[str] = None
168
302
  token: Optional[str] = None
169
303
  room: Optional[str] = None
304
+ mode: str = "preview"
305
+ session_id: Optional[str] = None
170
306
 
171
307
 
172
308
  class PreviewAgent:
@@ -178,6 +314,7 @@ class PreviewAgent:
178
314
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
179
315
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
180
316
  self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
317
+ self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
181
318
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
182
319
  self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
183
320
  self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
@@ -187,6 +324,12 @@ class PreviewAgent:
187
324
  self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
188
325
  self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
189
326
  self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
327
+ self.local_camera_motion = str(
328
+ # Production needs an always-on detector. The preview publisher
329
+ # remains the single camera owner when a session starts, so this
330
+ # does not require the separate OpenCV vision server.
331
+ cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
332
+ ).lower() in ("1", "true", "yes", "on")
190
333
 
191
334
  self._shutdown = asyncio.Event()
192
335
  self._task: Optional[asyncio.Task] = None
@@ -200,8 +343,16 @@ class PreviewAgent:
200
343
  # with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
201
344
  # _check_vision_session() below for the full design.
202
345
  self._vision_session_id: Optional[str] = None
346
+ self._vision_trigger_in_flight = False
203
347
  self._vision_hard_deadline: float = 0.0
204
348
  self._vision_last_activity: float = 0.0
349
+ self.production_mode = False
350
+ self._remote_session_ended = False
351
+ self._last_device_config_poll = 0.0
352
+ self._motion_reference = None
353
+ self._last_motion_sample = 0.0
354
+ self._motion_capture: Optional[VideoCapture] = None
355
+ self._motion_capture_lock = asyncio.Lock()
205
356
 
206
357
  async def run(self) -> None:
207
358
  enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
@@ -231,6 +382,11 @@ class PreviewAgent:
231
382
  asyncio.create_task(self._poll_loop()),
232
383
  asyncio.create_task(self._heartbeat_loop()),
233
384
  ]
385
+ # In production the robot's motion detector sends the webhook. Do not
386
+ # open the same Windows camera locally unless explicitly requested;
387
+ # doing both creates a DirectShow ownership conflict with LiveKit.
388
+ if self.local_camera_motion:
389
+ tasks.append(asyncio.create_task(self._motion_loop()))
234
390
  await self._shutdown.wait()
235
391
  for t in tasks:
236
392
  t.cancel()
@@ -241,8 +397,70 @@ class PreviewAgent:
241
397
  if self._vision_server:
242
398
  self._vision_server.shutdown()
243
399
  await self._stop_publisher()
400
+ await self._stop_motion_capture()
244
401
  await self._session.aclose()
245
402
 
403
+ async def _stop_motion_capture(self) -> None:
404
+ async with self._motion_capture_lock:
405
+ capture = self._motion_capture
406
+ self._motion_capture = None
407
+ if capture is not None:
408
+ await asyncio.to_thread(capture.stop)
409
+
410
+ async def _motion_loop(self) -> None:
411
+ """Own the camera while idle and detect motion without a second process."""
412
+ while not self._shutdown.is_set():
413
+ if self._vision_session_id or self._current_state.active:
414
+ await self._stop_motion_capture()
415
+ await asyncio.sleep(0.25)
416
+ continue
417
+ try:
418
+ async with self._motion_capture_lock:
419
+ if self._motion_capture is None:
420
+ self._motion_capture = await asyncio.to_thread(
421
+ create_video_capture,
422
+ self.video_device,
423
+ self.width,
424
+ self.height,
425
+ self.fps,
426
+ )
427
+ self._motion_reference = None
428
+ if self._motion_capture is None:
429
+ continue
430
+ logger.info("motion camera opened by preview agent")
431
+ frame = await asyncio.to_thread(self._motion_capture.read)
432
+ if frame is not None:
433
+ self._detect_motion(frame)
434
+ except Exception as e:
435
+ logger.warning(f"motion camera unavailable: {e}")
436
+ await self._stop_motion_capture()
437
+ await asyncio.sleep(2.0)
438
+ await asyncio.sleep(0.1)
439
+
440
+ def _detect_motion(self, frame) -> None:
441
+ """Compare sparse RGB samples and trigger only meaningful scene changes."""
442
+ now = time.monotonic()
443
+ if now - self._last_motion_sample < 0.25:
444
+ return
445
+ self._last_motion_sample = now
446
+ try:
447
+ import numpy as np
448
+
449
+ data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
450
+ sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
451
+ previous = self._motion_reference
452
+ self._motion_reference = sample
453
+ if previous is None or previous.shape != sample.shape:
454
+ return
455
+ change = float(np.abs(sample - previous).mean())
456
+ threshold = float(os.getenv("VISION_MOTION_THRESHOLD", "12"))
457
+ if change >= threshold:
458
+ asyncio.create_task(
459
+ self._on_vision_motion({"source": "preview_camera", "change": change})
460
+ )
461
+ except Exception as e:
462
+ logger.debug(f"preview motion sampling failed: {e}")
463
+
246
464
  async def _resolve_device_id(self) -> None:
247
465
  """Look up our device_id from the scheduler using the token."""
248
466
  if not self._session or not self.device_token:
@@ -274,6 +492,9 @@ class PreviewAgent:
274
492
  # operator-preview state tear it down.
275
493
  await self._check_vision_session()
276
494
  if not self._vision_session_id:
495
+ await self._poll_trigger_command()
496
+ if not self._vision_session_id:
497
+ await self._poll_device_config()
277
498
  state = await self._fetch_preview_state()
278
499
  await self._apply_state(state)
279
500
  except Exception as e:
@@ -283,6 +504,46 @@ class PreviewAgent:
283
504
  except asyncio.TimeoutError:
284
505
  pass
285
506
 
507
+ async def _poll_trigger_command(self) -> None:
508
+ """Consume dashboard test triggers through the authenticated device path."""
509
+ if not self._session or not self.device_id or not self.device_token:
510
+ return
511
+ r = await self._session.get(
512
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/trigger-command",
513
+ headers={"Authorization": f"Bearer {self.device_token}"},
514
+ timeout=10.0,
515
+ )
516
+ r.raise_for_status()
517
+ data = r.json()
518
+ self.production_mode = bool(data.get("production_mode", self.production_mode))
519
+ if data.get("trigger"):
520
+ await self._on_vision_motion({"source": data.get("source", "dashboard")})
521
+
522
+ async def _poll_device_config(self) -> None:
523
+ """Apply dashboard-selected camera and microphone IDs before preview."""
524
+ if not self._session or not self.device_id or not self.device_token:
525
+ return
526
+ now = time.monotonic()
527
+ if now - self._last_device_config_poll < 5.0:
528
+ return
529
+ self._last_device_config_poll = now
530
+ try:
531
+ r = await self._session.get(
532
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/config",
533
+ headers={"Authorization": f"Bearer {self.device_token}"},
534
+ timeout=10.0,
535
+ )
536
+ r.raise_for_status()
537
+ data = r.json()
538
+ if data.get("video_device") is not None:
539
+ self.video_device = data["video_device"]
540
+ if data.get("audio_device") is not None:
541
+ self.audio_device = data["audio_device"]
542
+ if data.get("audio_output_device") is not None:
543
+ self.audio_output_device = data["audio_output_device"]
544
+ except Exception as e:
545
+ logger.debug(f"device config poll failed: {e}")
546
+
286
547
  async def _check_vision_session(self) -> None:
287
548
  """Decide whether the active motion-triggered session should keep
288
549
  holding the publisher.
@@ -314,6 +575,10 @@ class PreviewAgent:
314
575
 
315
576
  try:
316
577
  last_activity = await self._fetch_session_last_activity(self._vision_session_id)
578
+ if self._remote_session_ended:
579
+ logger.info("scheduler ended the vision session — stopping publisher")
580
+ await self._end_vision_session()
581
+ return
317
582
  if last_activity is not None and last_activity > self._vision_last_activity:
318
583
  self._vision_last_activity = last_activity
319
584
  except Exception as e:
@@ -341,6 +606,9 @@ class PreviewAgent:
341
606
  )
342
607
  r.raise_for_status()
343
608
  data = r.json()
609
+ if data.get("ended_at"):
610
+ self._remote_session_ended = True
611
+ return None
344
612
  ts = data.get("last_activity_at") or data.get("started_at")
345
613
  if not ts:
346
614
  return None
@@ -357,7 +625,8 @@ class PreviewAgent:
357
625
  # picked up (unrelated to this specific dispatch, but a real bug:
358
626
  # every silence-timeout before this fix leaked a permanently
359
627
  # "active" session).
360
- if self._session and self.device_token:
628
+ remote_session_ended = self._remote_session_ended
629
+ if not remote_session_ended and self._session and self.device_token:
361
630
  try:
362
631
  await self._session.post(
363
632
  f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id or self.robot_id}/end-session",
@@ -368,15 +637,24 @@ class PreviewAgent:
368
637
  except Exception as e:
369
638
  logger.warning(f"failed to notify scheduler of session end: {e}")
370
639
  self._vision_session_id = None
640
+ self._vision_trigger_in_flight = False
371
641
  self._vision_hard_deadline = 0.0
372
642
  self._vision_last_activity = 0.0
643
+ self._remote_session_ended = False
373
644
  await self._stop_publisher()
645
+ await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
374
646
  self._current_state = PreviewState()
375
647
 
376
648
  async def _heartbeat_loop(self) -> None:
377
649
  while not self._shutdown.is_set():
378
650
  if self.device_id and self.device_token:
379
- await _send_heartbeat(self.scheduler_url, self.device_id, self.device_token)
651
+ production_mode = await _send_heartbeat(
652
+ self.scheduler_url, self.device_id, self.device_token
653
+ )
654
+ if production_mode is not None:
655
+ self.production_mode = production_mode
656
+ if not production_mode and self._vision_session_id:
657
+ await self._end_vision_session()
380
658
  try:
381
659
  await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
382
660
  except asyncio.TimeoutError:
@@ -396,17 +674,20 @@ class PreviewAgent:
396
674
  return PreviewState()
397
675
  return PreviewState(
398
676
  active=True,
399
- url=data.get("url"),
677
+ url=_normalize_livekit_url(data.get("url")),
400
678
  token=data.get("token"),
401
679
  room=data.get("room"),
680
+ mode=data.get("mode", "preview"),
681
+ session_id=data.get("session_id"),
402
682
  )
403
683
 
404
684
  async def _apply_state(self, state: PreviewState) -> None:
405
685
  same = (
406
686
  state.active == self._current_state.active
407
687
  and state.room == self._current_state.room
408
- and state.token == self._current_state.token
409
688
  and state.url == self._current_state.url
689
+ and state.mode == self._current_state.mode
690
+ and state.session_id == self._current_state.session_id
410
691
  )
411
692
  if same:
412
693
  return
@@ -420,6 +701,7 @@ class PreviewAgent:
420
701
  await self._stop_publisher()
421
702
  if not state.url or not state.token or not state.room:
422
703
  return
704
+ pub = None
423
705
  try:
424
706
  pub = LiveKitPublisher(state.url, state.token, state.room, self)
425
707
  await pub.start()
@@ -427,6 +709,11 @@ class PreviewAgent:
427
709
  logger.info(f"joined preview room {state.room}")
428
710
  except Exception as e:
429
711
  logger.error(f"failed to start publisher: {e}")
712
+ if pub is not None:
713
+ try:
714
+ await pub.stop()
715
+ except Exception as cleanup_error:
716
+ logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
430
717
 
431
718
  async def _stop_publisher(self) -> None:
432
719
  if self._publisher:
@@ -482,15 +769,40 @@ class PreviewAgent:
482
769
  self._vision_server = None
483
770
 
484
771
  async def _on_vision_motion(self, payload: dict) -> None:
772
+ # vision_trigger_cooldown is a MINIMUM SPACING between trigger
773
+ # attempts, not "an active session is fine to interrupt" -- without
774
+ # this separate check, continuous ambient motion (someone standing
775
+ # in frame) re-fires every cooldown window regardless of whether a
776
+ # conversation is already in progress, tearing down the room and
777
+ # restarting it before the greeting/response ever finishes playing.
778
+ # Only allow a new trigger once the previous vision session has
779
+ # actually ended (silence timeout, hard ceiling, or remote end).
780
+ if self._vision_session_id or self._vision_trigger_in_flight:
781
+ logger.debug("vision trigger suppressed (a vision session is already active)")
782
+ return
485
783
  now = time.time()
486
784
  if now - self._last_vision_trigger < self.vision_trigger_cooldown:
487
785
  logger.debug("vision trigger suppressed (cooldown)")
488
786
  return
489
787
  self._last_vision_trigger = now
788
+ if not self.production_mode:
789
+ logger.info("motion event ignored because production mode is OFF")
790
+ return
490
791
  if not self.device_id or not self.device_token or not self._session:
491
792
  logger.warning("vision motion event received but not enrolled yet — ignoring")
492
793
  return
794
+ self._vision_trigger_in_flight = True
493
795
  logger.info("motion detected by RoboVisionAI_PI — requesting a session")
796
+ # The cue is feedback only; never block session allocation on speaker
797
+ await self._stop_motion_capture()
798
+ # DirectShow can return a stale handle for a short interval after
799
+ # release. Give the driver time to finish teardown before reopening it
800
+ # for the LiveKit publisher.
801
+ await asyncio.sleep(0.5)
802
+ # I/O. Camera/mic join and the preset greeting are the critical path.
803
+ asyncio.create_task(
804
+ asyncio.to_thread(_play_audio_effect, self.audio_output_device, "motion")
805
+ )
494
806
  try:
495
807
  r = await self._session.post(
496
808
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -501,10 +813,11 @@ class PreviewAgent:
501
813
  data = r.json()
502
814
  except Exception as e:
503
815
  logger.error(f"request-session failed: {e}")
816
+ self._vision_trigger_in_flight = False
504
817
  return
505
818
  state = PreviewState(
506
819
  active=True,
507
- url=data.get("server_url"),
820
+ url=_normalize_livekit_url(data.get("server_url")),
508
821
  token=data.get("token"),
509
822
  room=data.get("room_name"),
510
823
  )
@@ -512,8 +825,10 @@ class PreviewAgent:
512
825
  voice_config = data.get("voice_config")
513
826
  now = time.time()
514
827
  self._vision_session_id = session_id
828
+ self._vision_trigger_in_flight = False
515
829
  self._vision_hard_deadline = now + self.vision_session_seconds
516
830
  self._vision_last_activity = now
831
+ self._remote_session_ended = False
517
832
  self._current_state = state
518
833
  # If the scheduler returned a voice config, let the agent know by
519
834
  # posting it to our local webhook endpoint. The preview agent itself
@@ -554,32 +869,18 @@ class LiveKitPublisher:
554
869
  self._stop_event = asyncio.Event()
555
870
  self._tasks: list[asyncio.Task] = []
556
871
  self._capture: Optional["VideoCapture"] = None
872
+ # Motion sampling state belongs to the publisher instance. Keeping it
873
+ # initialized here prevents shutdown/reopen paths from raising while
874
+ # the camera is being handed between preview and voice sessions.
875
+ self._last_motion_sample = 0.0
557
876
 
558
877
  async def start(self) -> None:
559
878
  self.room = self.rtc.Room()
560
879
  await self.room.connect(self.url, self.token)
561
880
 
562
- # Video
563
- self.video_source = self.rtc.VideoSource(self.agent.width, self.agent.height)
564
- self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
565
- vopts = self.rtc.TrackPublishOptions()
566
- vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
567
- await self.room.local_participant.publish_track(self.video_track, vopts)
568
-
569
- # Audio
570
- self.audio_source = self.rtc.AudioSource(48000, 1)
571
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
572
- aopts = self.rtc.TrackPublishOptions()
573
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
574
- await self.room.local_participant.publish_track(self.audio_track, aopts)
575
-
576
- # Register speaker playback (subscribe to the voice agent's TTS audio
577
- # track) BEFORE opening the camera. Camera open is a slow/occasionally
578
- # hanging blocking call (see create_video_capture below), and since
579
- # asyncio is single-threaded, running it inline here would stall the
580
- # entire event loop — including receiving the agent's greeting audio
581
- # — until it finished, so a short "Hello friend!" greeting could be
582
- # over and gone before we ever got a chance to subscribe to it.
881
+ # Register after signaling. Registering during Room.connect can invoke
882
+ # callbacks while the native LiveKit participant state is incomplete;
883
+ # on Windows that has caused an unrecoverable native client abort.
583
884
  self._playback_streams: dict = {}
584
885
 
585
886
  def _on_track_subscribed(track, publication, participant):
@@ -587,22 +888,79 @@ class LiveKitPublisher:
587
888
  if track.kind != self.rtc.TrackKind.KIND_AUDIO:
588
889
  return
589
890
  if participant.identity == self.room.local_participant.identity:
590
- return # don't play our own mic back
891
+ return
591
892
  self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
592
893
 
593
894
  self.room.on("track_subscribed", _on_track_subscribed)
594
895
  logger.info("audio out: track_subscribed listener registered")
595
896
 
596
- if has_audio():
897
+ # Open and warm the camera before creating the native LiveKit source.
898
+ # Windows webcams may ignore the requested 640x480 mode and return a
899
+ # different size (for example 480x360); capturing that frame into a
900
+ # mismatched VideoSource can abort the native SDK.
901
+ video_enabled = str(self.agent.video_device).lower() not in ("none", "", "false", "null")
902
+ first_frame = None
903
+ if video_enabled:
904
+ try:
905
+ self._capture = await asyncio.to_thread(
906
+ create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
907
+ )
908
+ except Exception as e:
909
+ # Camera availability must not block the microphone/session.
910
+ # Windows camera drivers can fail during a reopen while the
911
+ # audio path remains healthy and should still accept speech.
912
+ logger.warning(f"camera unavailable for this session; continuing audio-only: {e}")
913
+ self._capture = None
914
+ if self._capture:
915
+ for _ in range(12):
916
+ try:
917
+ first_frame = await asyncio.to_thread(self._capture.read)
918
+ except Exception as e:
919
+ logger.warning(f"camera read failed during warm-up; continuing audio-only: {e}")
920
+ first_frame = None
921
+ break
922
+ if first_frame is not None:
923
+ logger.info(
924
+ "camera warm-up produced a valid frame (%sx%s)",
925
+ first_frame.width,
926
+ first_frame.height,
927
+ )
928
+ break
929
+ await asyncio.sleep(0.08)
930
+ if first_frame is None:
931
+ self._capture.stop()
932
+ self._capture = None
933
+
934
+ # Do not publish dummy tracks for explicitly disabled devices. Apart
935
+ # from misleading the worker, creating native LiveKit sources for a
936
+ # disabled Windows device has caused unstable track publication.
937
+ audio_enabled = str(self.agent.audio_device).lower() not in ("none", "", "false", "null")
938
+ if first_frame is not None:
939
+ self.video_source = self.rtc.VideoSource(first_frame.width, first_frame.height)
940
+ self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
941
+ vopts = self.rtc.TrackPublishOptions()
942
+ vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
943
+ await self.room.local_participant.publish_track(self.video_track, vopts)
944
+ self.video_source.capture_frame(first_frame)
945
+
946
+ if audio_enabled:
947
+ self.audio_source = self.rtc.AudioSource(48000, 1)
948
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
949
+ aopts = self.rtc.TrackPublishOptions()
950
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
951
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
952
+
953
+ # Register speaker playback (subscribe to the voice agent's TTS audio
954
+ # track) BEFORE opening the camera. Camera open is a slow/occasionally
955
+ # hanging blocking call (see create_video_capture below), and since
956
+ # asyncio is single-threaded, running it inline here would stall the
957
+ # entire event loop — including receiving the agent's greeting audio
958
+ # — until it finished, so a short "Hello friend!" greeting could be
959
+ # over and gone before we ever got a chance to subscribe to it.
960
+ if has_audio() and audio_enabled:
597
961
  self._tasks.append(asyncio.create_task(self._audio_loop()))
598
962
 
599
- # Camera open can block for a long time (flaky USB/driver reopen) —
600
- # run it in a thread so it can't stall the event loop (and therefore
601
- # incoming agent audio / heartbeats / the playback subscribed above).
602
- self._capture = await asyncio.to_thread(
603
- create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps
604
- )
605
- if self._capture:
963
+ if self._capture and self.video_source:
606
964
  self._tasks.append(asyncio.create_task(self._video_loop()))
607
965
 
608
966
  async def _play_remote_audio(self, track, sid: str) -> None:
@@ -612,29 +970,174 @@ class LiveKitPublisher:
612
970
  logger.warning(f"pyaudio unavailable for playback: {e}")
613
971
  return
614
972
  OUT_RATE = 48000
973
+ OUT_CHANNELS = 2
615
974
  pa = pyaudio.PyAudio()
616
- out = pa.open(format=pyaudio.paInt16, channels=1, rate=OUT_RATE, output=True)
617
- logger.info(f"audio out: opened playback stream for {sid}")
975
+ # Same MME-vs-WASAPI gotcha as mic capture (see PyAudioCapture._resolve_device):
976
+ # PyAudio's global default output device resolves through MME, which on this
977
+ # machine will accept writes and report success with zero exceptions while
978
+ # never actually reaching the real speakers. Prefer WASAPI's default output,
979
+ # which is what Windows' own volume mixer/meter is backed by.
980
+ output_device_index = None
981
+ selected_output = str(self.agent.audio_output_device or "default")
982
+ if selected_output.strip().isdigit():
983
+ output_device_index = int(selected_output.strip())
984
+ try:
985
+ if selected_output.lower() == "default":
986
+ wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
987
+ idx = wasapi.get("defaultOutputDevice")
988
+ if idx is not None and idx >= 0:
989
+ output_device_index = idx
990
+ elif output_device_index is None:
991
+ needle = selected_output.lower()
992
+ for i in range(pa.get_device_count()):
993
+ info = pa.get_device_info_by_index(i)
994
+ if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
995
+ output_device_index = i
996
+ break
997
+ except Exception as e:
998
+ logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
999
+ out = pa.open(
1000
+ format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
1001
+ output_device_index=output_device_index,
1002
+ frames_per_buffer=960,
1003
+ )
1004
+ logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
618
1005
  frame_count = 0
1006
+ mismatch_logged = False
1007
+ try:
1008
+ import numpy as np
1009
+ except Exception as e:
1010
+ np = None
1011
+ logger.warning(f"numpy unavailable for playback resampling: {e}")
1012
+
1013
+ # Frames arrive over the network in irregular bursts (TTS streaming,
1014
+ # scheduling jitter); writing each one straight to a blocking PyAudio
1015
+ # stream inline ties the audio device's write timing to that jitter,
1016
+ # which is what produced "heavily distorted/staticky" playback even
1017
+ # with matching rate/channels. Decouple the two with a small jitter
1018
+ # buffer: a writer thread drains a queue into PyAudio on its own
1019
+ # steady pace, independent of how unevenly frames actually arrive.
1020
+ import queue
1021
+ import threading
1022
+ write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1023
+ written_frames = [0]
1024
+ PREBUFFER_CHUNKS = 5
1025
+ stream_failed = threading.Event()
1026
+
1027
+ def _safe_write(chunk: bytes) -> bool:
1028
+ if stream_failed.is_set():
1029
+ return False
1030
+ try:
1031
+ out.write(chunk)
1032
+ return True
1033
+ except Exception as e:
1034
+ stream_failed.set()
1035
+ logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
1036
+ return False
1037
+
1038
+ def _writer():
1039
+ buffered = []
1040
+ started = False
1041
+ while True:
1042
+ try:
1043
+ chunk = write_queue.get()
1044
+ except queue.Empty:
1045
+ continue
1046
+ if chunk is None:
1047
+ # Drain any short tail when the remote track ends.
1048
+ for pending in buffered:
1049
+ if not _safe_write(pending):
1050
+ break
1051
+ break
1052
+ if not started:
1053
+ buffered.append(chunk)
1054
+ if len(buffered) < PREBUFFER_CHUNKS:
1055
+ continue
1056
+ for pending in buffered:
1057
+ if not _safe_write(pending):
1058
+ break
1059
+ buffered.clear()
1060
+ if stream_failed.is_set():
1061
+ break
1062
+ started = True
1063
+ continue
1064
+ try:
1065
+ if _safe_write(chunk):
1066
+ written_frames[0] += 1
1067
+ if written_frames[0] == 1:
1068
+ logger.info(f"audio out: writer thread wrote its first chunk for {sid}")
1069
+ elif stream_failed.is_set():
1070
+ break
1071
+ except Exception as e:
1072
+ # A single write failing (e.g. a transient device hiccup) must
1073
+ # not silently kill the whole thread — that leaves every
1074
+ # later frame queued with nothing left to consume them,
1075
+ # which looks exactly like "no audio at all" even though
1076
+ # frames kept arriving fine. Log loudly and keep going.
1077
+ logger.warning(f"audio out write error (continuing): {e}")
1078
+
1079
+ writer_thread = threading.Thread(target=_writer, daemon=True)
1080
+ writer_thread.start()
1081
+
619
1082
  try:
620
1083
  stream = self.rtc.AudioStream(track=track)
621
1084
  async for frame in stream:
622
1085
  af = frame.frame if hasattr(frame, "frame") else frame
623
- data = bytes(af.data)
624
1086
  frame_count += 1
625
1087
  if frame_count == 1:
626
- logger.info(f"audio out: first frame received for {sid} (rate={af.sample_rate})")
627
- if af.sample_rate != OUT_RATE:
628
- # Frames from Kokoro/TTS are already 48kHz in this stack;
629
- # a mismatch here would need resampling like the Pi
630
- # client does, but isn't expected on the local test loop.
631
- logger.debug(
632
- f"audio out: unexpected sample rate {af.sample_rate}, expected {OUT_RATE}"
1088
+ logger.info(
1089
+ f"audio out: first frame received for {sid} "
1090
+ f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
633
1091
  )
634
- out.write(data)
1092
+ in_rate = af.sample_rate
1093
+ in_channels = int(getattr(af, "num_channels", 1) or 1)
1094
+ raw = bytes(af.data)
1095
+ if np is not None:
1096
+ in_data = np.frombuffer(raw, dtype=np.int16)
1097
+ if in_channels > 1:
1098
+ in_data = in_data.reshape(-1, in_channels).mean(axis=1).astype(np.int16)
1099
+ else:
1100
+ in_data = raw
1101
+
1102
+ if in_rate == OUT_RATE or np is None:
1103
+ if in_rate != OUT_RATE and not mismatch_logged:
1104
+ mismatch_logged = True
1105
+ logger.warning(
1106
+ f"audio out: sample rate {in_rate} != {OUT_RATE} and numpy "
1107
+ f"unavailable — playing unresampled (expect distortion)"
1108
+ )
1109
+ mono_data = in_data
1110
+ else:
1111
+ # Mirror the real Pi client's resampling (pi-client/livekit_bridge.py):
1112
+ # writing raw bytes straight to a fixed-rate output stream when the
1113
+ # source rate differs (e.g. Kokoro's native rate vs our 48kHz stream)
1114
+ # is what produced the "heavily distorted/staticky" playback — every
1115
+ # frame needs resampling to OUT_RATE first, not just the ones that
1116
+ # happen to already match.
1117
+ if not mismatch_logged:
1118
+ mismatch_logged = True
1119
+ logger.info(f"audio out: resampling {in_rate}Hz -> {OUT_RATE}Hz for {sid}")
1120
+ ratio = OUT_RATE / in_rate
1121
+ n_out = int(len(in_data) * ratio)
1122
+ indices = np.linspace(0, len(in_data) - 1, n_out)
1123
+ out_data = np.interp(indices, np.arange(len(in_data)), in_data.astype(np.float64)).astype(np.int16)
1124
+ mono_data = out_data
1125
+
1126
+ if np is not None:
1127
+ # The Windows endpoint is stereo; duplicate mono LiveKit audio explicitly.
1128
+ data = np.repeat(mono_data[:, None], OUT_CHANNELS, axis=1).astype(np.int16).tobytes()
1129
+ else:
1130
+ data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
1131
+ write_queue.put(data)
635
1132
  except Exception as e:
636
1133
  logger.warning(f"audio out stream error: {e}")
637
1134
  finally:
1135
+ write_queue.put(None)
1136
+ writer_thread.join(timeout=2.0)
1137
+ logger.info(
1138
+ f"audio out: {sid} received {frame_count} frames, "
1139
+ f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
1140
+ )
638
1141
  out.stop_stream()
639
1142
  out.close()
640
1143
  pa.terminate()
@@ -669,6 +1172,7 @@ class LiveKitPublisher:
669
1172
  frame = await asyncio.to_thread(self._capture.read)
670
1173
  if frame is not None:
671
1174
  self.video_source.capture_frame(frame)
1175
+ self._detect_motion(frame)
672
1176
  elapsed = time.monotonic() - start
673
1177
  sleep_for = frame_interval - elapsed
674
1178
  if sleep_for > 0:
@@ -677,6 +1181,34 @@ class LiveKitPublisher:
677
1181
  except asyncio.TimeoutError:
678
1182
  pass
679
1183
 
1184
+ def _detect_motion(self, frame: "rtc.VideoFrame") -> None:
1185
+ """Detect motion from the already-published camera frame.
1186
+
1187
+ The preview publisher is the sole camera owner. Keeping motion
1188
+ detection here avoids opening the Windows camera a second time from a
1189
+ separate detector process, which caused black frames and crashes.
1190
+ """
1191
+ now = time.monotonic()
1192
+ if now - self._last_motion_sample < 0.25:
1193
+ return
1194
+ self._last_motion_sample = now
1195
+ try:
1196
+ import numpy as np
1197
+
1198
+ data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
1199
+ sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
1200
+ previous = self._motion_reference
1201
+ self._motion_reference = sample
1202
+ if previous is None or previous.shape != sample.shape:
1203
+ return
1204
+ change = float(np.abs(sample - previous).mean())
1205
+ if change >= float(os.getenv("VISION_MOTION_THRESHOLD", "12")):
1206
+ asyncio.create_task(
1207
+ self._on_vision_motion({"source": "preview_camera", "change": change})
1208
+ )
1209
+ except Exception as e:
1210
+ logger.debug(f"preview motion sampling failed: {e}")
1211
+
680
1212
  async def _audio_loop(self) -> None:
681
1213
  assert self.audio_source is not None
682
1214
  mic = create_audio_capture(self.agent.audio_device)
@@ -764,10 +1296,22 @@ class OpencvVideoCapture(VideoCapture):
764
1296
  self.cv2 = cv2
765
1297
  if isinstance(device, str) and device.startswith("/dev/video"):
766
1298
  device = int(device.replace("/dev/video", ""))
767
- self.cap = cv2.VideoCapture(device)
1299
+ elif isinstance(device, str) and device.isdigit():
1300
+ device = int(device)
1301
+ # MSMF frequently returns intermittent grab failures for USB cameras
1302
+ # on Windows. DirectShow is more stable for this long-lived stream;
1303
+ # retain the default backend as a fallback for unusual devices.
1304
+ if os.name == "nt" and isinstance(device, int):
1305
+ self.cap = cv2.VideoCapture(device, cv2.CAP_DSHOW)
1306
+ if not self.cap.isOpened():
1307
+ self.cap.release()
1308
+ self.cap = cv2.VideoCapture(device)
1309
+ else:
1310
+ self.cap = cv2.VideoCapture(device)
768
1311
  self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
769
1312
  self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
770
1313
  self.cap.set(cv2.CAP_PROP_FPS, fps)
1314
+ self._last_black_log = 0.0
771
1315
  self._ensure_frame()
772
1316
 
773
1317
  def _ensure_frame(self):
@@ -780,11 +1324,28 @@ class OpencvVideoCapture(VideoCapture):
780
1324
  ok, bgr = self.cap.read()
781
1325
  if not ok or bgr is None:
782
1326
  return None
1327
+ # OpenCV can report a successful read while a Windows camera driver
1328
+ # returns an effectively black frame (privacy shutter, wrong device,
1329
+ # or a stale DirectShow handle). Do not publish misleading video.
1330
+ mean = float(bgr.mean())
1331
+ if mean <= 1.0 and float(bgr.max()) <= 8.0:
1332
+ now = time.monotonic()
1333
+ if now - self._last_black_log >= 10.0:
1334
+ logger.error(
1335
+ "camera returned black frames from device %s; check camera selection, "
1336
+ "privacy shutter, and Windows camera permissions",
1337
+ self.cap,
1338
+ )
1339
+ self._last_black_log = now
1340
+ return None
1341
+ # The current LiveKit RTC SDK exposes RGB/RGBA frame types, not BGR.
1342
+ # OpenCV captures BGR, so convert before constructing the frame.
1343
+ rgb = self.cv2.cvtColor(bgr, self.cv2.COLOR_BGR2RGB)
783
1344
  return rtc.VideoFrame(
784
- width=bgr.shape[1],
785
- height=bgr.shape[0],
786
- type=rtc.VideoBufferType.BGR,
787
- data=bgr.tobytes(),
1345
+ width=rgb.shape[1],
1346
+ height=rgb.shape[0],
1347
+ type=rtc.VideoBufferType.RGB24,
1348
+ data=rgb.tobytes(),
788
1349
  )
789
1350
 
790
1351
  def stop(self):
@@ -812,7 +1373,7 @@ class Picamera2Capture(VideoCapture):
812
1373
  return rtc.VideoFrame(
813
1374
  width=self.width,
814
1375
  height=self.height,
815
- type=rtc.VideoBufferType.RGB,
1376
+ type=rtc.VideoBufferType.RGB24,
816
1377
  data=arr.tobytes(),
817
1378
  )
818
1379
 
@@ -890,6 +1451,8 @@ class PyAudioCapture(AudioCapture):
890
1451
  def _resolve_device(self, device: str | int | None) -> Optional[int]:
891
1452
  if isinstance(device, int):
892
1453
  return device
1454
+ if isinstance(device, str) and device.strip().isdigit():
1455
+ return int(device.strip())
893
1456
  if device is None or device == "default":
894
1457
  # PyAudio's global default resolves through the MME host API,
895
1458
  # which on Windows can silently capture pure silence (no error,