infinicode 2.8.36 → 2.8.38
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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +4586 -364
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +56 -0
- package/dist/robopark/add-robot.d.ts +2 -0
- package/dist/robopark/add-robot.js +9 -0
- package/dist/robopark/secrets.d.ts +8 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/setup.d.ts +3 -0
- package/dist/robopark/setup.js +9 -2
- package/dist/robopark-cli.js +22 -0
- package/package.json +105 -105
- package/packages/robopark/scheduler/main.py +1573 -108
- package/packages/robopark/scheduler/preview_agent.py +620 -57
- package/packages/robopark/scheduler/robot_supervisor.py +729 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/packages/robopark/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/packages/robopark/scheduler/scripts/robopark-supervisor.service +20 -0
- package/packages/robopark/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/packages/robopark/scheduler/supervisor.example.json +26 -0
- package/packages/robopark/scheduler/vision_motion_trigger.py +101 -0
|
@@ -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 =
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
563
|
-
|
|
564
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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(
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
785
|
-
height=
|
|
786
|
-
type=rtc.VideoBufferType.
|
|
787
|
-
data=
|
|
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.
|
|
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,
|