robopark 2.8.35 → 3.0.0
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/README.md +88 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +289 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +466 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark-cli.js +799 -0
- package/package.json +21 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/fleet_config.json +75 -0
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -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,282 @@ import httpx
|
|
|
62
66
|
|
|
63
67
|
logger = logging.getLogger("robopark.preview_agent")
|
|
64
68
|
|
|
69
|
+
_DEVICE_INVENTORY_CACHE: Optional[dict] = None
|
|
70
|
+
_DEVICE_INVENTORY_CACHE_AT = 0.0
|
|
71
|
+
DEVICE_INVENTORY_CACHE_SECONDS = 5.0
|
|
72
|
+
ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stable_audio_label(value: object) -> str:
|
|
76
|
+
"""Compare USB product names without volatile ALSA card coordinates."""
|
|
77
|
+
import re
|
|
78
|
+
return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
|
|
82
|
+
if preferred:
|
|
83
|
+
wanted = _stable_audio_label(preferred)
|
|
84
|
+
match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
|
|
85
|
+
if match and match.get("name"):
|
|
86
|
+
return str(match["name"])
|
|
87
|
+
selected_text = str(selected)
|
|
88
|
+
selected_label = _stable_audio_label(selected_text)
|
|
89
|
+
match = next(
|
|
90
|
+
(
|
|
91
|
+
item for item in items
|
|
92
|
+
if str(item.get("id")) == selected_text
|
|
93
|
+
or _stable_audio_label(item.get("name")) == selected_label
|
|
94
|
+
),
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
return str(match.get("name")) if match and match.get("name") else selected_text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
101
|
+
"""Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
|
|
102
|
+
if not url:
|
|
103
|
+
return url
|
|
104
|
+
for scheme in ("ws", "wss"):
|
|
105
|
+
prefix = f"{scheme}://localhost"
|
|
106
|
+
if url.startswith(prefix):
|
|
107
|
+
return f"{scheme}://127.0.0.1" + url[len(prefix):]
|
|
108
|
+
return url
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
|
|
112
|
+
"""Apply gain and measure peak without audioop (removed in Python 3.13)."""
|
|
113
|
+
from array import array
|
|
114
|
+
|
|
115
|
+
samples = array("h")
|
|
116
|
+
samples.frombytes(data[:len(data) - (len(data) % 2)])
|
|
117
|
+
if sys.byteorder != "little":
|
|
118
|
+
samples.byteswap()
|
|
119
|
+
peak = 0
|
|
120
|
+
for index, value in enumerate(samples):
|
|
121
|
+
scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
|
|
122
|
+
samples[index] = scaled
|
|
123
|
+
peak = max(peak, abs(scaled))
|
|
124
|
+
if sys.byteorder != "little":
|
|
125
|
+
samples.byteswap()
|
|
126
|
+
return samples.tobytes(), peak
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
|
|
130
|
+
"""Linearly resample a PCM16 mono chunk using only the standard library."""
|
|
131
|
+
from array import array
|
|
132
|
+
|
|
133
|
+
if source_rate == target_rate or len(data) < 4:
|
|
134
|
+
return data
|
|
135
|
+
source = array("h")
|
|
136
|
+
source.frombytes(data[:len(data) - (len(data) % 2)])
|
|
137
|
+
if sys.byteorder != "little":
|
|
138
|
+
source.byteswap()
|
|
139
|
+
target_count = max(1, round(len(source) * target_rate / source_rate))
|
|
140
|
+
target = array("h", [0]) * target_count
|
|
141
|
+
scale = source_rate / target_rate
|
|
142
|
+
last = len(source) - 1
|
|
143
|
+
for index in range(target_count):
|
|
144
|
+
position = min(last, index * scale)
|
|
145
|
+
left = int(position)
|
|
146
|
+
right = min(last, left + 1)
|
|
147
|
+
fraction = position - left
|
|
148
|
+
target[index] = max(-32768, min(32767, round(
|
|
149
|
+
source[left] + (source[right] - source[left]) * fraction
|
|
150
|
+
)))
|
|
151
|
+
if sys.byteorder != "little":
|
|
152
|
+
target.byteswap()
|
|
153
|
+
return target.tobytes()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
157
|
+
"""Play a short local cue without involving the voice pipeline."""
|
|
158
|
+
sample_rate = 48000
|
|
159
|
+
channels = 2
|
|
160
|
+
if effect == "motion":
|
|
161
|
+
notes = ((880, 0.09), (1320, 0.13))
|
|
162
|
+
else:
|
|
163
|
+
notes = ((660, 0.10), (440, 0.16))
|
|
164
|
+
selected = str(selected_output or "default")
|
|
165
|
+
frames = bytearray()
|
|
166
|
+
for frequency, duration in notes:
|
|
167
|
+
count = int(sample_rate * duration)
|
|
168
|
+
for n in range(count):
|
|
169
|
+
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
170
|
+
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
171
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
172
|
+
|
|
173
|
+
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
174
|
+
import re
|
|
175
|
+
import subprocess
|
|
176
|
+
from media_lock import media_lock
|
|
177
|
+
|
|
178
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
179
|
+
if not match:
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
with media_lock("speaker", timeout=3.0):
|
|
183
|
+
result = subprocess.run(
|
|
184
|
+
[
|
|
185
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
186
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
187
|
+
],
|
|
188
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
189
|
+
)
|
|
190
|
+
except TimeoutError as exc:
|
|
191
|
+
logger.warning("audio effect skipped: %s", exc)
|
|
192
|
+
return
|
|
193
|
+
if result.returncode:
|
|
194
|
+
logger.warning(
|
|
195
|
+
"audio effect failed on %s: %s",
|
|
196
|
+
match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
import pyaudio
|
|
202
|
+
except Exception:
|
|
203
|
+
return
|
|
204
|
+
pa = pyaudio.PyAudio()
|
|
205
|
+
device_index = None
|
|
206
|
+
try:
|
|
207
|
+
if selected.strip().isdigit():
|
|
208
|
+
device_index = int(selected.strip())
|
|
209
|
+
elif selected.lower() == "default":
|
|
210
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
211
|
+
device_index = wasapi.get("defaultOutputDevice")
|
|
212
|
+
else:
|
|
213
|
+
needle = selected.lower()
|
|
214
|
+
for i in range(pa.get_device_count()):
|
|
215
|
+
info = pa.get_device_info_by_index(i)
|
|
216
|
+
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
217
|
+
device_index = i
|
|
218
|
+
break
|
|
219
|
+
stream = pa.open(
|
|
220
|
+
format=pyaudio.paInt16,
|
|
221
|
+
channels=channels,
|
|
222
|
+
rate=sample_rate,
|
|
223
|
+
output=True,
|
|
224
|
+
output_device_index=device_index,
|
|
225
|
+
)
|
|
226
|
+
stream.write(bytes(frames))
|
|
227
|
+
stream.stop_stream()
|
|
228
|
+
stream.close()
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.debug(f"audio effect unavailable: {e}")
|
|
231
|
+
finally:
|
|
232
|
+
pa.terminate()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_device_inventory() -> dict:
|
|
236
|
+
"""Return discoverable camera and audio devices for dashboard selection."""
|
|
237
|
+
global _DEVICE_INVENTORY_CACHE, _DEVICE_INVENTORY_CACHE_AT
|
|
238
|
+
now = time.monotonic()
|
|
239
|
+
if (_DEVICE_INVENTORY_CACHE is not None
|
|
240
|
+
and now - _DEVICE_INVENTORY_CACHE_AT < DEVICE_INVENTORY_CACHE_SECONDS):
|
|
241
|
+
return _DEVICE_INVENTORY_CACHE
|
|
242
|
+
|
|
243
|
+
inventory = {"video": [], "audio_input": [], "audio_output": [], "platform": sys.platform}
|
|
244
|
+
inventory["video"].append({"id": "auto", "name": "Auto detect"})
|
|
245
|
+
inventory["video"].append({"id": "none", "name": "Disable camera"})
|
|
246
|
+
|
|
247
|
+
# RoboVisionAI_PI owns the production camera. Prefer its native inventory
|
|
248
|
+
# so this process never probes an already-open V4L2 device just to fill a
|
|
249
|
+
# dashboard dropdown.
|
|
250
|
+
robovision_inventory = None
|
|
251
|
+
try:
|
|
252
|
+
response = httpx.get(ROBOVISION_MEDIA_URL, timeout=0.8)
|
|
253
|
+
if response.is_success:
|
|
254
|
+
robovision_inventory = response.json()
|
|
255
|
+
for key in ("video", "audio_input", "audio_output"):
|
|
256
|
+
if isinstance(robovision_inventory.get(key), list):
|
|
257
|
+
inventory[key] = robovision_inventory[key]
|
|
258
|
+
inventory["source"] = robovision_inventory.get("source", "robovision_pi")
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
if not robovision_inventory:
|
|
263
|
+
try:
|
|
264
|
+
import cv2
|
|
265
|
+
candidates = sorted(glob.glob("/dev/video*")) if os.name != "nt" else [str(i) for i in range(10)]
|
|
266
|
+
for candidate in candidates:
|
|
267
|
+
value = int(candidate) if os.name == "nt" else candidate
|
|
268
|
+
backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
|
|
269
|
+
cap = cv2.VideoCapture(value, backend)
|
|
270
|
+
if cap.isOpened():
|
|
271
|
+
device_id = str(value)
|
|
272
|
+
inventory["video"].append({"id": device_id, "name": f"Camera {candidate}", "backend": "dshow" if os.name == "nt" else "v4l2"})
|
|
273
|
+
cap.release()
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.debug(f"camera inventory unavailable: {e}")
|
|
276
|
+
|
|
277
|
+
if not robovision_inventory:
|
|
278
|
+
try:
|
|
279
|
+
import pyaudio
|
|
280
|
+
pa = pyaudio.PyAudio()
|
|
281
|
+
default_in = None
|
|
282
|
+
default_out = None
|
|
283
|
+
try:
|
|
284
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
285
|
+
default_in = wasapi.get("defaultInputDevice")
|
|
286
|
+
default_out = wasapi.get("defaultOutputDevice")
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
inventory["audio_input"].append({"id": "default", "name": "System default input"})
|
|
290
|
+
inventory["audio_output"].append({"id": "default", "name": "System default output"})
|
|
291
|
+
for i in range(pa.get_device_count()):
|
|
292
|
+
info = pa.get_device_info_by_index(i)
|
|
293
|
+
name = str(info.get("name", f"Audio device {i}"))
|
|
294
|
+
item = {"id": str(i), "name": name, "host_api": str(info.get("hostApi", ""))}
|
|
295
|
+
if info.get("maxInputChannels", 0) > 0:
|
|
296
|
+
item["default"] = i == default_in
|
|
297
|
+
inventory["audio_input"].append(item.copy())
|
|
298
|
+
if info.get("maxOutputChannels", 0) > 0:
|
|
299
|
+
item["default"] = i == default_out
|
|
300
|
+
inventory["audio_output"].append(item.copy())
|
|
301
|
+
pa.terminate()
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.debug(f"audio inventory unavailable: {e}")
|
|
304
|
+
|
|
305
|
+
media_health = {
|
|
306
|
+
"service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
|
|
307
|
+
"camera_access": None,
|
|
308
|
+
"audio_access": None,
|
|
309
|
+
"camera_worker": None,
|
|
310
|
+
"camera_stalled": None,
|
|
311
|
+
"camera_frame_age_seconds": None,
|
|
312
|
+
}
|
|
313
|
+
if sys.platform.startswith("linux"):
|
|
314
|
+
camera_nodes = [
|
|
315
|
+
str(item.get("id")) for item in inventory.get("video", [])
|
|
316
|
+
if str(item.get("id", "")).startswith("/dev/video")
|
|
317
|
+
]
|
|
318
|
+
sound_nodes = glob.glob("/dev/snd/pcm*")
|
|
319
|
+
media_health["camera_access"] = bool(camera_nodes) and all(
|
|
320
|
+
os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
|
|
321
|
+
)
|
|
322
|
+
media_health["audio_access"] = bool(sound_nodes) and all(
|
|
323
|
+
os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
|
|
324
|
+
)
|
|
325
|
+
try:
|
|
326
|
+
camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
|
|
327
|
+
if camera_response.is_success:
|
|
328
|
+
camera_status = camera_response.json()
|
|
329
|
+
media_health["camera_worker"] = bool(camera_status.get("worker_started"))
|
|
330
|
+
media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
|
|
331
|
+
media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
|
|
332
|
+
except Exception:
|
|
333
|
+
pass
|
|
334
|
+
inventory["media_health"] = media_health
|
|
335
|
+
|
|
336
|
+
_DEVICE_INVENTORY_CACHE = inventory
|
|
337
|
+
_DEVICE_INVENTORY_CACHE_AT = now
|
|
338
|
+
logger.info(
|
|
339
|
+
f"device inventory: {len(inventory['video']) - 2} cameras, "
|
|
340
|
+
f"{len(inventory['audio_input']) - 1} inputs, "
|
|
341
|
+
f"{len(inventory['audio_output']) - 1} outputs"
|
|
342
|
+
)
|
|
343
|
+
return inventory
|
|
344
|
+
|
|
65
345
|
CONFIG_DIR = Path.home() / ".robopark"
|
|
66
346
|
CONFIG_FILE = CONFIG_DIR / "preview_agent.json"
|
|
67
347
|
TOKEN_FILE = CONFIG_DIR / "device_token"
|
|
@@ -70,7 +350,7 @@ DEFAULT_VIDEO_WIDTH = 640
|
|
|
70
350
|
DEFAULT_VIDEO_HEIGHT = 480
|
|
71
351
|
DEFAULT_FPS = 15
|
|
72
352
|
DEFAULT_POLL_INTERVAL = 3.0
|
|
73
|
-
DEFAULT_HEARTBEAT_INTERVAL =
|
|
353
|
+
DEFAULT_HEARTBEAT_INTERVAL = 5.0
|
|
74
354
|
DEFAULT_VISION_WEBHOOK_PORT = 5057
|
|
75
355
|
DEFAULT_VISION_TRIGGER_COOLDOWN = 20.0
|
|
76
356
|
DEFAULT_VISION_SESSION_SECONDS = 90.0
|
|
@@ -78,7 +358,10 @@ DEFAULT_VISION_SESSION_SECONDS = 90.0
|
|
|
78
358
|
# (POST /api/sessions/{id}/keepalive, called by the voice agent — a
|
|
79
359
|
# different repo, not this one) before preview_agent tears it down.
|
|
80
360
|
# vision_session_seconds remains a hard ceiling regardless of activity.
|
|
81
|
-
|
|
361
|
+
# Cloud STT/LLM/TTS can legitimately leave a session quiet for several
|
|
362
|
+
# seconds. The voice agent has its own VAD-aware 60s idle policy; this remote
|
|
363
|
+
# safety timeout must not preempt a turn while it is thinking or speaking.
|
|
364
|
+
DEFAULT_VISION_SILENCE_TIMEOUT = 60.0
|
|
82
365
|
|
|
83
366
|
|
|
84
367
|
def _load_config() -> dict:
|
|
@@ -108,15 +391,51 @@ def _save_token(token: str) -> None:
|
|
|
108
391
|
os.chmod(TOKEN_FILE, 0o600)
|
|
109
392
|
|
|
110
393
|
|
|
111
|
-
|
|
112
|
-
"""
|
|
394
|
+
def _mesh_proxy_headers() -> dict:
|
|
395
|
+
"""Add hub authentication only when the runtime supplied it.
|
|
396
|
+
|
|
397
|
+
Direct LAN scheduler calls work unchanged. Tailscale calls use the hub's
|
|
398
|
+
/robopark proxy, which needs this separate mesh credential while the normal
|
|
399
|
+
Authorization header remains the scheduler device token.
|
|
400
|
+
"""
|
|
401
|
+
token = os.getenv("ROBOPARK_MESH_TOKEN", "").strip()
|
|
402
|
+
return {"X-RoboPark-Mesh-Token": token} if token else {}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def _bootstrap_mesh_device(scheduler_url: str, robot_id: str) -> tuple[str, str]:
|
|
406
|
+
"""Create or recover this robot's scheduler identity using mesh auth."""
|
|
407
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
408
|
+
response = await client.post(
|
|
409
|
+
f"{scheduler_url.rstrip('/')}/api/devices/bootstrap",
|
|
410
|
+
json={"name": robot_id, "lan_ip": _get_lan_ip(), "livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
411
|
+
timeout=30.0,
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
data = response.json()
|
|
415
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
416
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
417
|
+
if not device_id or not device_token:
|
|
418
|
+
raise RuntimeError("mesh bootstrap did not return device credentials")
|
|
419
|
+
_save_token(device_token)
|
|
420
|
+
cfg = _load_config()
|
|
421
|
+
cfg["device_id"] = device_id
|
|
422
|
+
cfg["device_token"] = device_token
|
|
423
|
+
cfg["scheduler_url"] = scheduler_url
|
|
424
|
+
_save_config(cfg)
|
|
425
|
+
logger.info("mesh bootstrap resolved scheduler device %s", device_id)
|
|
426
|
+
return device_id, device_token
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> tuple[str, str]:
|
|
430
|
+
"""Enroll this Pi and return the exact scheduler identity and token."""
|
|
113
431
|
import socket
|
|
114
432
|
payload = {
|
|
115
433
|
"enrollment_token": enrollment_token,
|
|
116
434
|
"name": robot_id,
|
|
117
435
|
"lan_ip": _get_lan_ip(),
|
|
436
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None,
|
|
118
437
|
}
|
|
119
|
-
async with httpx.AsyncClient() as client:
|
|
438
|
+
async with httpx.AsyncClient(headers=_mesh_proxy_headers()) as client:
|
|
120
439
|
r = await client.post(
|
|
121
440
|
f"{scheduler_url.rstrip('/')}/api/devices/enroll",
|
|
122
441
|
json=payload,
|
|
@@ -124,16 +443,17 @@ async def _enroll(scheduler_url: str, enrollment_token: str, robot_id: str) -> s
|
|
|
124
443
|
)
|
|
125
444
|
r.raise_for_status()
|
|
126
445
|
data = r.json()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
446
|
+
device_id = str(data.get("device_id") or "").strip()
|
|
447
|
+
device_token = str(data.get("device_token") or "").strip()
|
|
448
|
+
if not device_id or not device_token:
|
|
449
|
+
raise RuntimeError("enrollment did not return device credentials")
|
|
130
450
|
_save_token(device_token)
|
|
131
451
|
cfg = _load_config()
|
|
132
|
-
cfg["device_id"] =
|
|
452
|
+
cfg["device_id"] = device_id
|
|
133
453
|
cfg["scheduler_url"] = data.get("scheduler_url", scheduler_url)
|
|
134
454
|
_save_config(cfg)
|
|
135
|
-
logger.info(
|
|
136
|
-
return device_token
|
|
455
|
+
logger.info("enrolled as device %s", device_id)
|
|
456
|
+
return device_id, device_token
|
|
137
457
|
|
|
138
458
|
|
|
139
459
|
def _get_lan_ip() -> Optional[str]:
|
|
@@ -148,17 +468,39 @@ def _get_lan_ip() -> Optional[str]:
|
|
|
148
468
|
return None
|
|
149
469
|
|
|
150
470
|
|
|
151
|
-
async def _send_heartbeat(
|
|
471
|
+
async def _send_heartbeat(
|
|
472
|
+
scheduler_url: str, device_id: str, token: str
|
|
473
|
+
) -> tuple[Optional[bool], bool]:
|
|
152
474
|
try:
|
|
475
|
+
inventory = _get_device_inventory()
|
|
476
|
+
headers = {"Authorization": f"Bearer {token}", **_mesh_proxy_headers()}
|
|
153
477
|
async with httpx.AsyncClient() as client:
|
|
154
|
-
await client.post(
|
|
478
|
+
response = await client.post(
|
|
155
479
|
f"{scheduler_url.rstrip('/')}/api/devices/{device_id}/heartbeat",
|
|
156
|
-
json={"status": "online", "ip": _get_lan_ip()
|
|
157
|
-
|
|
480
|
+
json={"status": "online", "ip": _get_lan_ip(), "device_inventory": inventory,
|
|
481
|
+
"livekit_url": os.getenv("ROBOPARK_LIVEKIT_URL") or None},
|
|
482
|
+
headers=headers,
|
|
158
483
|
timeout=10.0,
|
|
159
484
|
)
|
|
485
|
+
if response.status_code in (401, 404):
|
|
486
|
+
logger.warning(
|
|
487
|
+
"heartbeat credentials rejected for %s (%s)",
|
|
488
|
+
device_id,
|
|
489
|
+
response.status_code,
|
|
490
|
+
)
|
|
491
|
+
return None, False
|
|
492
|
+
response.raise_for_status()
|
|
493
|
+
logger.info(
|
|
494
|
+
"heartbeat accepted: inventory source=%s camera=%d mic=%d speaker=%d",
|
|
495
|
+
inventory.get("source", "local"),
|
|
496
|
+
len(inventory.get("video", [])),
|
|
497
|
+
len(inventory.get("audio_input", [])),
|
|
498
|
+
len(inventory.get("audio_output", [])),
|
|
499
|
+
)
|
|
500
|
+
return bool(response.json().get("production_mode", False)), True
|
|
160
501
|
except Exception as e:
|
|
161
|
-
logger.
|
|
502
|
+
logger.warning(f"heartbeat failed: {e}")
|
|
503
|
+
return None, True
|
|
162
504
|
|
|
163
505
|
|
|
164
506
|
@dataclass
|
|
@@ -167,6 +509,8 @@ class PreviewState:
|
|
|
167
509
|
url: Optional[str] = None
|
|
168
510
|
token: Optional[str] = None
|
|
169
511
|
room: Optional[str] = None
|
|
512
|
+
mode: str = "preview"
|
|
513
|
+
session_id: Optional[str] = None
|
|
170
514
|
|
|
171
515
|
|
|
172
516
|
class PreviewAgent:
|
|
@@ -178,6 +522,17 @@ class PreviewAgent:
|
|
|
178
522
|
self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
|
|
179
523
|
self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
|
|
180
524
|
self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
|
|
525
|
+
# Scheduler inventory IDs come from RoboVision/sounddevice. They are
|
|
526
|
+
# not guaranteed to equal PyAudio's device indexes, so capture must
|
|
527
|
+
# use the resolved hardware name reported in that same inventory.
|
|
528
|
+
self.audio_capture_device = self.audio_device
|
|
529
|
+
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
530
|
+
# Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
|
|
531
|
+
# number the same ALSA cards differently, so resolve the selected ID
|
|
532
|
+
# to its hardware name before opening any playback stream.
|
|
533
|
+
self.audio_playback_device = self.audio_output_device
|
|
534
|
+
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
535
|
+
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
|
|
181
536
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
182
537
|
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
183
538
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -187,6 +542,17 @@ class PreviewAgent:
|
|
|
187
542
|
self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
|
|
188
543
|
self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
|
|
189
544
|
self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
|
|
545
|
+
self.local_camera_motion = str(
|
|
546
|
+
# Production needs an always-on detector. The preview publisher
|
|
547
|
+
# remains the single camera owner when a session starts, so this
|
|
548
|
+
# does not require the separate OpenCV vision server.
|
|
549
|
+
cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
|
|
550
|
+
).lower() in ("1", "true", "yes", "on")
|
|
551
|
+
# The character greeting is the production acknowledgement. A local
|
|
552
|
+
# motion beep serializes on the same ALSA output and delays allocation.
|
|
553
|
+
self.motion_cue_enabled = str(
|
|
554
|
+
cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
|
|
555
|
+
).lower() in ("1", "true", "yes", "on")
|
|
190
556
|
|
|
191
557
|
self._shutdown = asyncio.Event()
|
|
192
558
|
self._task: Optional[asyncio.Task] = None
|
|
@@ -200,19 +566,96 @@ class PreviewAgent:
|
|
|
200
566
|
# with a hard ceiling — see DEFAULT_VISION_SILENCE_TIMEOUT above and
|
|
201
567
|
# _check_vision_session() below for the full design.
|
|
202
568
|
self._vision_session_id: Optional[str] = None
|
|
569
|
+
self._vision_trigger_in_flight = False
|
|
570
|
+
self._speaker_test_in_flight = False
|
|
571
|
+
# The USB adapter is an exclusive ALSA endpoint. Keep operator tests
|
|
572
|
+
# and motion cues from racing each other while LiveKit is paused.
|
|
573
|
+
self._speaker_operation_lock = asyncio.Lock()
|
|
203
574
|
self._vision_hard_deadline: float = 0.0
|
|
204
575
|
self._vision_last_activity: float = 0.0
|
|
576
|
+
self.production_mode = False
|
|
577
|
+
self._robovision_motion_state: Optional[bool] = None
|
|
578
|
+
self._remote_session_ended = False
|
|
579
|
+
self._last_device_config_poll = 0.0
|
|
580
|
+
self._motion_reference = None
|
|
581
|
+
self._last_motion_sample = 0.0
|
|
582
|
+
self._motion_capture: Optional[VideoCapture] = None
|
|
583
|
+
self._motion_capture_lock = asyncio.Lock()
|
|
584
|
+
self._mesh_bootstrap_ready = False
|
|
585
|
+
self._next_mesh_bootstrap = 0.0
|
|
586
|
+
self._reported_pipeline: set[tuple[str, str]] = set()
|
|
587
|
+
|
|
588
|
+
async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
|
|
589
|
+
details: Optional[dict] = None, once: bool = True) -> None:
|
|
590
|
+
"""Report a real robot-side transition for the Park test timeline."""
|
|
591
|
+
if not self._session or not self.device_id or not self.device_token:
|
|
592
|
+
return
|
|
593
|
+
session_id = self._vision_session_id or self._current_state.session_id
|
|
594
|
+
key = (session_id or "idle", stage)
|
|
595
|
+
if once and key in self._reported_pipeline:
|
|
596
|
+
return
|
|
597
|
+
try:
|
|
598
|
+
response = await self._session.post(
|
|
599
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
|
|
600
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
601
|
+
json={"stage": stage, "status": status, "message": message,
|
|
602
|
+
"session_id": session_id, "source": "preview_agent",
|
|
603
|
+
"details": details or {}},
|
|
604
|
+
timeout=5.0,
|
|
605
|
+
)
|
|
606
|
+
response.raise_for_status()
|
|
607
|
+
if once:
|
|
608
|
+
self._reported_pipeline.add(key)
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
logger.debug("pipeline event %s was not accepted: %s", stage, exc)
|
|
611
|
+
|
|
612
|
+
async def _ensure_mesh_identity(self) -> bool:
|
|
613
|
+
if not _mesh_proxy_headers():
|
|
614
|
+
return bool(self.device_id and self.device_token)
|
|
615
|
+
now = time.monotonic()
|
|
616
|
+
if self._mesh_bootstrap_ready:
|
|
617
|
+
return True
|
|
618
|
+
if now < self._next_mesh_bootstrap:
|
|
619
|
+
return False
|
|
620
|
+
self._next_mesh_bootstrap = now + 5.0
|
|
621
|
+
try:
|
|
622
|
+
self.device_id, self.device_token = await _bootstrap_mesh_device(
|
|
623
|
+
self.scheduler_url, self.robot_id
|
|
624
|
+
)
|
|
625
|
+
self._mesh_bootstrap_ready = True
|
|
626
|
+
return True
|
|
627
|
+
except Exception as exc:
|
|
628
|
+
logger.warning("mesh device bootstrap failed: %s", exc)
|
|
629
|
+
return False
|
|
205
630
|
|
|
206
631
|
async def run(self) -> None:
|
|
207
632
|
enrollment_token = os.getenv("ENROLLMENT_TOKEN") or self.enrollment_token
|
|
633
|
+
# A UI-minted token identifies a specific pre-created device row.
|
|
634
|
+
# Consume it before generic mesh recovery so heartbeats cannot bind to
|
|
635
|
+
# a stale same-name identity.
|
|
208
636
|
if not self.device_token and enrollment_token:
|
|
209
|
-
|
|
637
|
+
try:
|
|
638
|
+
self.device_id, self.device_token = await _enroll(
|
|
639
|
+
self.scheduler_url, enrollment_token, self.robot_id
|
|
640
|
+
)
|
|
641
|
+
self._mesh_bootstrap_ready = True
|
|
642
|
+
except Exception as exc:
|
|
643
|
+
if not _mesh_proxy_headers():
|
|
644
|
+
raise
|
|
645
|
+
# Enrollment tokens are intentionally one-time. A service
|
|
646
|
+
# reinstall can retain the original systemd argument after
|
|
647
|
+
# its credential file was removed; recover through the hub's
|
|
648
|
+
# authenticated mesh path rather than crash-loop forever.
|
|
649
|
+
logger.warning("device enrollment failed; recovering through mesh bootstrap: %s", exc)
|
|
650
|
+
await self._ensure_mesh_identity()
|
|
651
|
+
elif _mesh_proxy_headers():
|
|
652
|
+
await self._ensure_mesh_identity()
|
|
210
653
|
|
|
211
654
|
if not self.device_token:
|
|
212
655
|
logger.error("no DEVICE_TOKEN and no ENROLLMENT_TOKEN; cannot poll scheduler")
|
|
213
656
|
sys.exit(1)
|
|
214
657
|
|
|
215
|
-
self._session = httpx.AsyncClient()
|
|
658
|
+
self._session = httpx.AsyncClient(headers=_mesh_proxy_headers())
|
|
216
659
|
|
|
217
660
|
# Must come after self._session exists — _resolve_device_id() guards
|
|
218
661
|
# on it and silently no-ops otherwise. On a fresh enroll (no cached
|
|
@@ -231,6 +674,11 @@ class PreviewAgent:
|
|
|
231
674
|
asyncio.create_task(self._poll_loop()),
|
|
232
675
|
asyncio.create_task(self._heartbeat_loop()),
|
|
233
676
|
]
|
|
677
|
+
# In production the robot's motion detector sends the webhook. Do not
|
|
678
|
+
# open the same Windows camera locally unless explicitly requested;
|
|
679
|
+
# doing both creates a DirectShow ownership conflict with LiveKit.
|
|
680
|
+
if self.local_camera_motion:
|
|
681
|
+
tasks.append(asyncio.create_task(self._motion_loop()))
|
|
234
682
|
await self._shutdown.wait()
|
|
235
683
|
for t in tasks:
|
|
236
684
|
t.cancel()
|
|
@@ -241,8 +689,75 @@ class PreviewAgent:
|
|
|
241
689
|
if self._vision_server:
|
|
242
690
|
self._vision_server.shutdown()
|
|
243
691
|
await self._stop_publisher()
|
|
692
|
+
await self._stop_motion_capture()
|
|
244
693
|
await self._session.aclose()
|
|
245
694
|
|
|
695
|
+
async def _stop_motion_capture(self) -> None:
|
|
696
|
+
async with self._motion_capture_lock:
|
|
697
|
+
capture = self._motion_capture
|
|
698
|
+
self._motion_capture = None
|
|
699
|
+
if capture is not None:
|
|
700
|
+
await asyncio.to_thread(capture.stop)
|
|
701
|
+
|
|
702
|
+
async def _motion_loop(self) -> None:
|
|
703
|
+
"""Detect motion from RoboVision's shared stream without owning V4L2."""
|
|
704
|
+
while not self._shutdown.is_set():
|
|
705
|
+
if not self.production_mode:
|
|
706
|
+
await self._stop_motion_capture()
|
|
707
|
+
await asyncio.sleep(0.5)
|
|
708
|
+
continue
|
|
709
|
+
if self._vision_session_id or self._current_state.active:
|
|
710
|
+
await self._stop_motion_capture()
|
|
711
|
+
await asyncio.sleep(0.25)
|
|
712
|
+
continue
|
|
713
|
+
try:
|
|
714
|
+
async with self._motion_capture_lock:
|
|
715
|
+
if self._motion_capture is None:
|
|
716
|
+
self._motion_capture = await asyncio.to_thread(
|
|
717
|
+
create_video_capture,
|
|
718
|
+
self.video_device,
|
|
719
|
+
self.width,
|
|
720
|
+
self.height,
|
|
721
|
+
self.fps,
|
|
722
|
+
self.robovision_url,
|
|
723
|
+
)
|
|
724
|
+
self._motion_reference = None
|
|
725
|
+
if self._motion_capture is None:
|
|
726
|
+
continue
|
|
727
|
+
logger.info("motion sampler connected to RoboVision shared stream")
|
|
728
|
+
frame = await asyncio.to_thread(self._motion_capture.read)
|
|
729
|
+
if frame is not None:
|
|
730
|
+
self._detect_motion(frame)
|
|
731
|
+
except Exception as e:
|
|
732
|
+
logger.warning(f"motion camera unavailable: {e}")
|
|
733
|
+
await self._stop_motion_capture()
|
|
734
|
+
await asyncio.sleep(2.0)
|
|
735
|
+
await asyncio.sleep(0.1)
|
|
736
|
+
|
|
737
|
+
def _detect_motion(self, frame) -> None:
|
|
738
|
+
"""Compare sparse RGB samples and trigger only meaningful scene changes."""
|
|
739
|
+
now = time.monotonic()
|
|
740
|
+
if now - self._last_motion_sample < 0.25:
|
|
741
|
+
return
|
|
742
|
+
self._last_motion_sample = now
|
|
743
|
+
try:
|
|
744
|
+
import numpy as np
|
|
745
|
+
|
|
746
|
+
data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
|
|
747
|
+
sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
|
|
748
|
+
previous = self._motion_reference
|
|
749
|
+
self._motion_reference = sample
|
|
750
|
+
if previous is None or previous.shape != sample.shape:
|
|
751
|
+
return
|
|
752
|
+
change = float(np.abs(sample - previous).mean())
|
|
753
|
+
threshold = float(os.getenv("VISION_MOTION_THRESHOLD", "12"))
|
|
754
|
+
if change >= threshold:
|
|
755
|
+
asyncio.create_task(
|
|
756
|
+
self._on_vision_motion({"source": "preview_camera", "change": change})
|
|
757
|
+
)
|
|
758
|
+
except Exception as e:
|
|
759
|
+
logger.debug(f"preview motion sampling failed: {e}")
|
|
760
|
+
|
|
246
761
|
async def _resolve_device_id(self) -> None:
|
|
247
762
|
"""Look up our device_id from the scheduler using the token."""
|
|
248
763
|
if not self._session or not self.device_token:
|
|
@@ -274,6 +789,9 @@ class PreviewAgent:
|
|
|
274
789
|
# operator-preview state tear it down.
|
|
275
790
|
await self._check_vision_session()
|
|
276
791
|
if not self._vision_session_id:
|
|
792
|
+
await self._poll_trigger_command()
|
|
793
|
+
if not self._vision_session_id:
|
|
794
|
+
await self._poll_device_config()
|
|
277
795
|
state = await self._fetch_preview_state()
|
|
278
796
|
await self._apply_state(state)
|
|
279
797
|
except Exception as e:
|
|
@@ -283,6 +801,83 @@ class PreviewAgent:
|
|
|
283
801
|
except asyncio.TimeoutError:
|
|
284
802
|
pass
|
|
285
803
|
|
|
804
|
+
async def _poll_trigger_command(self) -> None:
|
|
805
|
+
"""Consume dashboard test triggers through the authenticated device path."""
|
|
806
|
+
if not self._session or not self.device_id or not self.device_token:
|
|
807
|
+
return
|
|
808
|
+
r = await self._session.get(
|
|
809
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/trigger-command",
|
|
810
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
811
|
+
timeout=10.0,
|
|
812
|
+
)
|
|
813
|
+
r.raise_for_status()
|
|
814
|
+
data = r.json()
|
|
815
|
+
self.production_mode = bool(data.get("production_mode", self.production_mode))
|
|
816
|
+
if data.get("trigger"):
|
|
817
|
+
await self._on_vision_motion({"source": data.get("source", "dashboard")})
|
|
818
|
+
|
|
819
|
+
async def _poll_device_config(self) -> None:
|
|
820
|
+
"""Apply dashboard-selected camera and microphone IDs before preview."""
|
|
821
|
+
if not self._session or not self.device_id or not self.device_token:
|
|
822
|
+
return
|
|
823
|
+
now = time.monotonic()
|
|
824
|
+
if now - self._last_device_config_poll < 5.0:
|
|
825
|
+
return
|
|
826
|
+
self._last_device_config_poll = now
|
|
827
|
+
try:
|
|
828
|
+
r = await self._session.get(
|
|
829
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/config",
|
|
830
|
+
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
831
|
+
timeout=10.0,
|
|
832
|
+
)
|
|
833
|
+
r.raise_for_status()
|
|
834
|
+
data = r.json()
|
|
835
|
+
changed = False
|
|
836
|
+
if data.get("video_device") is not None:
|
|
837
|
+
changed = changed or self.video_device != data["video_device"]
|
|
838
|
+
self.video_device = data["video_device"]
|
|
839
|
+
if data.get("audio_device") is not None:
|
|
840
|
+
changed = changed or self.audio_device != data["audio_device"]
|
|
841
|
+
self.audio_device = data["audio_device"]
|
|
842
|
+
inventory = data.get("device_inventory") or {}
|
|
843
|
+
selected = str(self.audio_device)
|
|
844
|
+
self.audio_capture_device = _resolve_inventory_audio(
|
|
845
|
+
inventory.get("audio_input", []), selected,
|
|
846
|
+
os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
|
|
847
|
+
)
|
|
848
|
+
if data.get("audio_output_device") is not None:
|
|
849
|
+
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
850
|
+
self.audio_output_device = data["audio_output_device"]
|
|
851
|
+
inventory = data.get("device_inventory") or {}
|
|
852
|
+
selected = str(self.audio_output_device)
|
|
853
|
+
self.audio_playback_device = _resolve_inventory_audio(
|
|
854
|
+
inventory.get("audio_output", []), selected,
|
|
855
|
+
os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
|
|
856
|
+
)
|
|
857
|
+
if changed:
|
|
858
|
+
await self._sync_robovision_media_config()
|
|
859
|
+
except Exception as e:
|
|
860
|
+
logger.debug(f"device config poll failed: {e}")
|
|
861
|
+
|
|
862
|
+
async def _sync_robovision_media_config(self) -> None:
|
|
863
|
+
"""Apply scheduler media choices to the local RoboVisionAI_PI owner."""
|
|
864
|
+
try:
|
|
865
|
+
response = await self._session.post(
|
|
866
|
+
f"{self.robovision_url.rstrip('/')}/api/media/config",
|
|
867
|
+
json={
|
|
868
|
+
"video_device": self.video_device,
|
|
869
|
+
"audio_device": self.audio_device,
|
|
870
|
+
"audio_output_device": self.audio_output_device,
|
|
871
|
+
},
|
|
872
|
+
timeout=1.0,
|
|
873
|
+
)
|
|
874
|
+
if response.is_success:
|
|
875
|
+
global _DEVICE_INVENTORY_CACHE
|
|
876
|
+
_DEVICE_INVENTORY_CACHE = None
|
|
877
|
+
except Exception:
|
|
878
|
+
# RoboVision is optional on laptops and simulation nodes.
|
|
879
|
+
pass
|
|
880
|
+
|
|
286
881
|
async def _check_vision_session(self) -> None:
|
|
287
882
|
"""Decide whether the active motion-triggered session should keep
|
|
288
883
|
holding the publisher.
|
|
@@ -314,6 +909,10 @@ class PreviewAgent:
|
|
|
314
909
|
|
|
315
910
|
try:
|
|
316
911
|
last_activity = await self._fetch_session_last_activity(self._vision_session_id)
|
|
912
|
+
if self._remote_session_ended:
|
|
913
|
+
logger.info("scheduler ended the vision session — stopping publisher")
|
|
914
|
+
await self._end_vision_session()
|
|
915
|
+
return
|
|
317
916
|
if last_activity is not None and last_activity > self._vision_last_activity:
|
|
318
917
|
self._vision_last_activity = last_activity
|
|
319
918
|
except Exception as e:
|
|
@@ -341,6 +940,9 @@ class PreviewAgent:
|
|
|
341
940
|
)
|
|
342
941
|
r.raise_for_status()
|
|
343
942
|
data = r.json()
|
|
943
|
+
if data.get("ended_at"):
|
|
944
|
+
self._remote_session_ended = True
|
|
945
|
+
return None
|
|
344
946
|
ts = data.get("last_activity_at") or data.get("started_at")
|
|
345
947
|
if not ts:
|
|
346
948
|
return None
|
|
@@ -357,7 +959,8 @@ class PreviewAgent:
|
|
|
357
959
|
# picked up (unrelated to this specific dispatch, but a real bug:
|
|
358
960
|
# every silence-timeout before this fix leaked a permanently
|
|
359
961
|
# "active" session).
|
|
360
|
-
|
|
962
|
+
remote_session_ended = self._remote_session_ended
|
|
963
|
+
if not remote_session_ended and self._session and self.device_token:
|
|
361
964
|
try:
|
|
362
965
|
await self._session.post(
|
|
363
966
|
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id or self.robot_id}/end-session",
|
|
@@ -367,21 +970,101 @@ class PreviewAgent:
|
|
|
367
970
|
)
|
|
368
971
|
except Exception as e:
|
|
369
972
|
logger.warning(f"failed to notify scheduler of session end: {e}")
|
|
973
|
+
await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
|
|
370
974
|
self._vision_session_id = None
|
|
975
|
+
self._vision_trigger_in_flight = False
|
|
371
976
|
self._vision_hard_deadline = 0.0
|
|
372
977
|
self._vision_last_activity = 0.0
|
|
978
|
+
self._remote_session_ended = False
|
|
373
979
|
await self._stop_publisher()
|
|
980
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
|
|
374
981
|
self._current_state = PreviewState()
|
|
375
982
|
|
|
376
983
|
async def _heartbeat_loop(self) -> None:
|
|
377
984
|
while not self._shutdown.is_set():
|
|
985
|
+
if _mesh_proxy_headers() and not self._mesh_bootstrap_ready:
|
|
986
|
+
await self._ensure_mesh_identity()
|
|
378
987
|
if self.device_id and self.device_token:
|
|
379
|
-
await _send_heartbeat(
|
|
988
|
+
production_mode, credentials_ok = await _send_heartbeat(
|
|
989
|
+
self.scheduler_url, self.device_id, self.device_token
|
|
990
|
+
)
|
|
991
|
+
if not credentials_ok:
|
|
992
|
+
self._mesh_bootstrap_ready = False
|
|
993
|
+
self._next_mesh_bootstrap = 0.0
|
|
994
|
+
if production_mode is not None:
|
|
995
|
+
self.production_mode = production_mode
|
|
996
|
+
# Exactly one motion detector is active. With the default
|
|
997
|
+
# local sampler enabled, preview reads RoboVision's shared
|
|
998
|
+
# MJPEG stream and RoboVision only owns capture/encoding.
|
|
999
|
+
robovision_motion = bool(production_mode and not self.local_camera_motion)
|
|
1000
|
+
if self._robovision_motion_state != robovision_motion:
|
|
1001
|
+
try:
|
|
1002
|
+
response = await self._session.post(
|
|
1003
|
+
f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
|
|
1004
|
+
json={"enabled": robovision_motion}, timeout=1.5,
|
|
1005
|
+
)
|
|
1006
|
+
response.raise_for_status()
|
|
1007
|
+
self._robovision_motion_state = robovision_motion
|
|
1008
|
+
except Exception as exc:
|
|
1009
|
+
logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
|
|
1010
|
+
await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
|
|
1011
|
+
inventory = _get_device_inventory()
|
|
1012
|
+
real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
|
|
1013
|
+
real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1014
|
+
real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
|
|
1015
|
+
await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
|
|
1016
|
+
await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
|
|
1017
|
+
await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
|
|
1018
|
+
if not production_mode and self._vision_session_id:
|
|
1019
|
+
await self._end_vision_session()
|
|
1020
|
+
await self._poll_speaker_test()
|
|
380
1021
|
try:
|
|
381
1022
|
await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
|
|
382
1023
|
except asyncio.TimeoutError:
|
|
383
1024
|
pass
|
|
384
1025
|
|
|
1026
|
+
async def _poll_speaker_test(self) -> None:
|
|
1027
|
+
"""Execute queued speaker tests even when no optional supervisor runs."""
|
|
1028
|
+
if self._speaker_test_in_flight or not self._session or not self.device_id or not self.device_token:
|
|
1029
|
+
return
|
|
1030
|
+
headers = {"Authorization": f"Bearer {self.device_token}", **_mesh_proxy_headers()}
|
|
1031
|
+
try:
|
|
1032
|
+
response = await self._session.get(
|
|
1033
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/shell/next-speaker-test",
|
|
1034
|
+
headers=headers, timeout=5.0,
|
|
1035
|
+
)
|
|
1036
|
+
response.raise_for_status()
|
|
1037
|
+
request = response.json().get("request")
|
|
1038
|
+
if not request:
|
|
1039
|
+
return
|
|
1040
|
+
self._speaker_test_in_flight = True
|
|
1041
|
+
from robot_supervisor import _speaker_roundtrip_test
|
|
1042
|
+
async with self._speaker_operation_lock:
|
|
1043
|
+
# LiveKit owns the USB microphone and speaker continuously.
|
|
1044
|
+
# Pause it while holding the same lock as the motion cue so
|
|
1045
|
+
# nothing can reopen ALSA before the explicit test starts.
|
|
1046
|
+
restore_state = self._current_state if self._publisher else None
|
|
1047
|
+
if restore_state:
|
|
1048
|
+
await self._stop_publisher()
|
|
1049
|
+
await asyncio.sleep(1.0)
|
|
1050
|
+
try:
|
|
1051
|
+
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
1052
|
+
result["media_owner_paused"] = bool(restore_state)
|
|
1053
|
+
finally:
|
|
1054
|
+
if restore_state and restore_state.active:
|
|
1055
|
+
await self._start_publisher(restore_state)
|
|
1056
|
+
result_response = await self._session.post(
|
|
1057
|
+
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
|
|
1058
|
+
json={"kind": "speaker_test", "service": None, "payload": result,
|
|
1059
|
+
"request_id": request.get("id")},
|
|
1060
|
+
headers=headers, timeout=8.0,
|
|
1061
|
+
)
|
|
1062
|
+
result_response.raise_for_status()
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
logger.warning(f"speaker test execution failed: {e}")
|
|
1065
|
+
finally:
|
|
1066
|
+
self._speaker_test_in_flight = False
|
|
1067
|
+
|
|
385
1068
|
async def _fetch_preview_state(self) -> PreviewState:
|
|
386
1069
|
if not self._session or not self.device_token:
|
|
387
1070
|
return PreviewState()
|
|
@@ -396,17 +1079,20 @@ class PreviewAgent:
|
|
|
396
1079
|
return PreviewState()
|
|
397
1080
|
return PreviewState(
|
|
398
1081
|
active=True,
|
|
399
|
-
url=data.get("url"),
|
|
1082
|
+
url=_normalize_livekit_url(data.get("url")),
|
|
400
1083
|
token=data.get("token"),
|
|
401
1084
|
room=data.get("room"),
|
|
1085
|
+
mode=data.get("mode", "preview"),
|
|
1086
|
+
session_id=data.get("session_id"),
|
|
402
1087
|
)
|
|
403
1088
|
|
|
404
1089
|
async def _apply_state(self, state: PreviewState) -> None:
|
|
405
1090
|
same = (
|
|
406
1091
|
state.active == self._current_state.active
|
|
407
1092
|
and state.room == self._current_state.room
|
|
408
|
-
and state.token == self._current_state.token
|
|
409
1093
|
and state.url == self._current_state.url
|
|
1094
|
+
and state.mode == self._current_state.mode
|
|
1095
|
+
and state.session_id == self._current_state.session_id
|
|
410
1096
|
)
|
|
411
1097
|
if same:
|
|
412
1098
|
return
|
|
@@ -416,17 +1102,25 @@ class PreviewAgent:
|
|
|
416
1102
|
return
|
|
417
1103
|
await self._start_publisher(state)
|
|
418
1104
|
|
|
419
|
-
async def _start_publisher(self, state: PreviewState) ->
|
|
1105
|
+
async def _start_publisher(self, state: PreviewState) -> bool:
|
|
420
1106
|
await self._stop_publisher()
|
|
421
1107
|
if not state.url or not state.token or not state.room:
|
|
422
|
-
return
|
|
1108
|
+
return False
|
|
1109
|
+
pub = None
|
|
423
1110
|
try:
|
|
424
1111
|
pub = LiveKitPublisher(state.url, state.token, state.room, self)
|
|
425
1112
|
await pub.start()
|
|
426
1113
|
self._publisher = pub
|
|
427
1114
|
logger.info(f"joined preview room {state.room}")
|
|
1115
|
+
return True
|
|
428
1116
|
except Exception as e:
|
|
429
1117
|
logger.error(f"failed to start publisher: {e}")
|
|
1118
|
+
if pub is not None:
|
|
1119
|
+
try:
|
|
1120
|
+
await pub.stop()
|
|
1121
|
+
except Exception as cleanup_error:
|
|
1122
|
+
logger.debug(f"publisher cleanup after start failure: {cleanup_error}")
|
|
1123
|
+
return False
|
|
430
1124
|
|
|
431
1125
|
async def _stop_publisher(self) -> None:
|
|
432
1126
|
if self._publisher:
|
|
@@ -482,15 +1176,42 @@ class PreviewAgent:
|
|
|
482
1176
|
self._vision_server = None
|
|
483
1177
|
|
|
484
1178
|
async def _on_vision_motion(self, payload: dict) -> None:
|
|
1179
|
+
# vision_trigger_cooldown is a MINIMUM SPACING between trigger
|
|
1180
|
+
# attempts, not "an active session is fine to interrupt" -- without
|
|
1181
|
+
# this separate check, continuous ambient motion (someone standing
|
|
1182
|
+
# in frame) re-fires every cooldown window regardless of whether a
|
|
1183
|
+
# conversation is already in progress, tearing down the room and
|
|
1184
|
+
# restarting it before the greeting/response ever finishes playing.
|
|
1185
|
+
# Only allow a new trigger once the previous vision session has
|
|
1186
|
+
# actually ended (silence timeout, hard ceiling, or remote end).
|
|
1187
|
+
if self._vision_session_id or self._vision_trigger_in_flight:
|
|
1188
|
+
logger.debug("vision trigger suppressed (a vision session is already active)")
|
|
1189
|
+
return
|
|
485
1190
|
now = time.time()
|
|
486
1191
|
if now - self._last_vision_trigger < self.vision_trigger_cooldown:
|
|
487
1192
|
logger.debug("vision trigger suppressed (cooldown)")
|
|
488
1193
|
return
|
|
489
1194
|
self._last_vision_trigger = now
|
|
1195
|
+
if not self.production_mode:
|
|
1196
|
+
logger.info("motion event ignored because production mode is OFF")
|
|
1197
|
+
return
|
|
490
1198
|
if not self.device_id or not self.device_token or not self._session:
|
|
491
1199
|
logger.warning("vision motion event received but not enrolled yet — ignoring")
|
|
492
1200
|
return
|
|
1201
|
+
self._vision_trigger_in_flight = True
|
|
1202
|
+
# Observability must not add a scheduler round trip before the actual
|
|
1203
|
+
# session request. The reporter handles and logs its own errors.
|
|
1204
|
+
asyncio.create_task(self._report_pipeline(
|
|
1205
|
+
"motion_detected", "ok",
|
|
1206
|
+
f"Motion received from {payload.get('source', 'camera')}", once=False,
|
|
1207
|
+
))
|
|
493
1208
|
logger.info("motion detected by RoboVisionAI_PI — requesting a session")
|
|
1209
|
+
# RoboVision owns the physical camera and exposes a shared stream, so
|
|
1210
|
+
# there is no capture handle to tear down and no reason to sleep here.
|
|
1211
|
+
if self.motion_cue_enabled:
|
|
1212
|
+
logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
|
|
1213
|
+
async with self._speaker_operation_lock:
|
|
1214
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
494
1215
|
try:
|
|
495
1216
|
r = await self._session.post(
|
|
496
1217
|
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
|
|
@@ -501,10 +1222,12 @@ class PreviewAgent:
|
|
|
501
1222
|
data = r.json()
|
|
502
1223
|
except Exception as e:
|
|
503
1224
|
logger.error(f"request-session failed: {e}")
|
|
1225
|
+
await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
|
|
1226
|
+
self._vision_trigger_in_flight = False
|
|
504
1227
|
return
|
|
505
1228
|
state = PreviewState(
|
|
506
1229
|
active=True,
|
|
507
|
-
url=data.get("server_url"),
|
|
1230
|
+
url=_normalize_livekit_url(data.get("server_url")),
|
|
508
1231
|
token=data.get("token"),
|
|
509
1232
|
room=data.get("room_name"),
|
|
510
1233
|
)
|
|
@@ -512,23 +1235,47 @@ class PreviewAgent:
|
|
|
512
1235
|
voice_config = data.get("voice_config")
|
|
513
1236
|
now = time.time()
|
|
514
1237
|
self._vision_session_id = session_id
|
|
1238
|
+
self._vision_trigger_in_flight = False
|
|
515
1239
|
self._vision_hard_deadline = now + self.vision_session_seconds
|
|
516
1240
|
self._vision_last_activity = now
|
|
1241
|
+
self._remote_session_ended = False
|
|
517
1242
|
self._current_state = state
|
|
1243
|
+
await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
|
|
518
1244
|
# If the scheduler returned a voice config, let the agent know by
|
|
519
1245
|
# posting it to our local webhook endpoint. The preview agent itself
|
|
520
1246
|
# does not consume it, but this makes the config observable locally
|
|
521
1247
|
# and lets downstream components (audio server, vision, etc.) adapt.
|
|
522
1248
|
if voice_config and session_id:
|
|
523
1249
|
logger.info(f"scheduler voice config for session {session_id}: {voice_config}")
|
|
524
|
-
await self._start_publisher(state)
|
|
1250
|
+
joined = await self._start_publisher(state)
|
|
1251
|
+
if not joined:
|
|
1252
|
+
await self._report_pipeline(
|
|
1253
|
+
"livekit_join", "failed", "Robot could not connect to its assigned LiveKit route",
|
|
1254
|
+
details={"server_url": state.url}, once=False,
|
|
1255
|
+
)
|
|
1256
|
+
if session_id and self._session:
|
|
1257
|
+
try:
|
|
1258
|
+
response = await self._session.post(
|
|
1259
|
+
f"{self.scheduler_url.rstrip('/')}/api/robots/{self.device_id}/end-session",
|
|
1260
|
+
params={"reason": "livekit_join_failed"},
|
|
1261
|
+
headers={"Authorization": f"Bearer {self.device_token}"}, timeout=10.0,
|
|
1262
|
+
)
|
|
1263
|
+
response.raise_for_status()
|
|
1264
|
+
except Exception as e:
|
|
1265
|
+
logger.debug(f"could not end failed LiveKit session: {e}")
|
|
1266
|
+
self._vision_session_id = None
|
|
1267
|
+
self._vision_hard_deadline = 0.0
|
|
1268
|
+
self._vision_last_activity = 0.0
|
|
1269
|
+
self._current_state = PreviewState()
|
|
1270
|
+
return
|
|
525
1271
|
if session_id and self._session:
|
|
526
1272
|
try:
|
|
527
|
-
await self._session.post(
|
|
1273
|
+
response = await self._session.post(
|
|
528
1274
|
f"{self.scheduler_url.rstrip('/')}/api/sessions/{session_id}/joined",
|
|
529
1275
|
headers={"Authorization": f"Bearer {self.device_token}"},
|
|
530
1276
|
timeout=10.0,
|
|
531
1277
|
)
|
|
1278
|
+
response.raise_for_status()
|
|
532
1279
|
except Exception as e:
|
|
533
1280
|
logger.debug(f"could not mark session joined: {e}")
|
|
534
1281
|
|
|
@@ -554,32 +1301,51 @@ class LiveKitPublisher:
|
|
|
554
1301
|
self._stop_event = asyncio.Event()
|
|
555
1302
|
self._tasks: list[asyncio.Task] = []
|
|
556
1303
|
self._capture: Optional["VideoCapture"] = None
|
|
1304
|
+
self._mic_capture: Optional["AudioCapture"] = None
|
|
1305
|
+
self._mic_streaming = asyncio.Event()
|
|
1306
|
+
self._mic_error: Optional[str] = None
|
|
1307
|
+
# The launch hardware has no acoustic echo cancellation. Publishing
|
|
1308
|
+
# the amplified USB microphone while the robot speaker plays TTS makes
|
|
1309
|
+
# the voice worker hear itself and trigger barge-in, truncating or
|
|
1310
|
+
# chopping its own response. Keep the track alive with silence while
|
|
1311
|
+
# playback is active, plus a short room-echo decay tail.
|
|
1312
|
+
self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
|
|
1313
|
+
"1", "true", "yes", "on",
|
|
1314
|
+
)
|
|
1315
|
+
self._speaker_playback_active = threading.Event()
|
|
1316
|
+
self._speaker_gate_until = 0.0
|
|
1317
|
+
self._speaker_started_at = 0.0
|
|
1318
|
+
self._barge_in_until = 0.0
|
|
1319
|
+
self._echo_mic_floor = 0.0
|
|
1320
|
+
self._barge_in_candidate_frames = 0
|
|
1321
|
+
self._speaker_echo_tail = max(
|
|
1322
|
+
0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "350")) / 1000.0, 2.0)
|
|
1323
|
+
)
|
|
1324
|
+
self._adaptive_barge_in = str(
|
|
1325
|
+
os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
|
|
1326
|
+
).lower() in ("1", "true", "yes", "on")
|
|
1327
|
+
self._barge_in_min_peak = max(
|
|
1328
|
+
256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
|
|
1329
|
+
)
|
|
1330
|
+
self._barge_in_ratio = max(
|
|
1331
|
+
1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
|
|
1332
|
+
)
|
|
1333
|
+
self._barge_in_hold = max(
|
|
1334
|
+
0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
|
|
1335
|
+
)
|
|
1336
|
+
# Motion sampling state belongs to the publisher instance. Keeping it
|
|
1337
|
+
# initialized here prevents shutdown/reopen paths from raising while
|
|
1338
|
+
# the camera is being handed between preview and voice sessions.
|
|
1339
|
+
self._last_motion_sample = 0.0
|
|
557
1340
|
|
|
558
1341
|
async def start(self) -> None:
|
|
559
1342
|
self.room = self.rtc.Room()
|
|
560
1343
|
await self.room.connect(self.url, self.token)
|
|
1344
|
+
await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
|
|
561
1345
|
|
|
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.
|
|
1346
|
+
# Register after signaling. Registering during Room.connect can invoke
|
|
1347
|
+
# callbacks while the native LiveKit participant state is incomplete;
|
|
1348
|
+
# on Windows that has caused an unrecoverable native client abort.
|
|
583
1349
|
self._playback_streams: dict = {}
|
|
584
1350
|
|
|
585
1351
|
def _on_track_subscribed(track, publication, participant):
|
|
@@ -587,57 +1353,399 @@ class LiveKitPublisher:
|
|
|
587
1353
|
if track.kind != self.rtc.TrackKind.KIND_AUDIO:
|
|
588
1354
|
return
|
|
589
1355
|
if participant.identity == self.room.local_participant.identity:
|
|
590
|
-
return
|
|
1356
|
+
return
|
|
1357
|
+
self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
|
|
591
1358
|
self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
|
|
592
1359
|
|
|
593
1360
|
self.room.on("track_subscribed", _on_track_subscribed)
|
|
594
1361
|
logger.info("audio out: track_subscribed listener registered")
|
|
595
1362
|
|
|
596
|
-
|
|
597
|
-
|
|
1363
|
+
# Open and warm the camera before creating the native LiveKit source.
|
|
1364
|
+
# Windows webcams may ignore the requested 640x480 mode and return a
|
|
1365
|
+
# different size (for example 480x360); capturing that frame into a
|
|
1366
|
+
# mismatched VideoSource can abort the native SDK.
|
|
1367
|
+
video_enabled = str(self.agent.video_device).lower() not in ("none", "", "false", "null")
|
|
1368
|
+
first_frame = None
|
|
1369
|
+
if video_enabled:
|
|
1370
|
+
try:
|
|
1371
|
+
self._capture = await asyncio.to_thread(
|
|
1372
|
+
create_video_capture, self.agent.video_device, self.agent.width, self.agent.height, self.agent.fps,
|
|
1373
|
+
self.agent.robovision_url if self.agent.use_robovision_camera else None,
|
|
1374
|
+
)
|
|
1375
|
+
except Exception as e:
|
|
1376
|
+
# Camera availability must not block the microphone/session.
|
|
1377
|
+
# Windows camera drivers can fail during a reopen while the
|
|
1378
|
+
# audio path remains healthy and should still accept speech.
|
|
1379
|
+
logger.warning(f"camera unavailable for this session; continuing audio-only: {e}")
|
|
1380
|
+
self._capture = None
|
|
1381
|
+
if self._capture:
|
|
1382
|
+
for _ in range(12):
|
|
1383
|
+
try:
|
|
1384
|
+
first_frame = await asyncio.to_thread(self._capture.read)
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
logger.warning(f"camera read failed during warm-up; continuing audio-only: {e}")
|
|
1387
|
+
first_frame = None
|
|
1388
|
+
break
|
|
1389
|
+
if first_frame is not None:
|
|
1390
|
+
logger.info(
|
|
1391
|
+
"camera warm-up produced a valid frame (%sx%s)",
|
|
1392
|
+
first_frame.width,
|
|
1393
|
+
first_frame.height,
|
|
1394
|
+
)
|
|
1395
|
+
break
|
|
1396
|
+
await asyncio.sleep(0.08)
|
|
1397
|
+
if first_frame is None:
|
|
1398
|
+
self._capture.stop()
|
|
1399
|
+
self._capture = None
|
|
1400
|
+
|
|
1401
|
+
# Do not publish dummy tracks for explicitly disabled devices. Apart
|
|
1402
|
+
# from misleading the worker, creating native LiveKit sources for a
|
|
1403
|
+
# disabled Windows device has caused unstable track publication.
|
|
1404
|
+
audio_enabled = str(self.agent.audio_device).lower() not in ("none", "", "false", "null")
|
|
1405
|
+
if first_frame is not None:
|
|
1406
|
+
self.video_source = self.rtc.VideoSource(first_frame.width, first_frame.height)
|
|
1407
|
+
self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
|
|
1408
|
+
vopts = self.rtc.TrackPublishOptions()
|
|
1409
|
+
vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
|
|
1410
|
+
await self.room.local_participant.publish_track(self.video_track, vopts)
|
|
1411
|
+
self.video_source.capture_frame(first_frame)
|
|
1412
|
+
await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
|
|
1413
|
+
|
|
1414
|
+
if audio_enabled:
|
|
1415
|
+
self.audio_source = self.rtc.AudioSource(48000, 1)
|
|
1416
|
+
self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
|
|
1417
|
+
aopts = self.rtc.TrackPublishOptions()
|
|
1418
|
+
aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
|
|
1419
|
+
await self.room.local_participant.publish_track(self.audio_track, aopts)
|
|
598
1420
|
|
|
599
|
-
#
|
|
600
|
-
#
|
|
601
|
-
#
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1421
|
+
# Register speaker playback (subscribe to the voice agent's TTS audio
|
|
1422
|
+
# track) BEFORE opening the camera. Camera open is a slow/occasionally
|
|
1423
|
+
# hanging blocking call (see create_video_capture below), and since
|
|
1424
|
+
# asyncio is single-threaded, running it inline here would stall the
|
|
1425
|
+
# entire event loop — including receiving the agent's greeting audio
|
|
1426
|
+
# — until it finished, so a short "Hello friend!" greeting could be
|
|
1427
|
+
# over and gone before we ever got a chance to subscribe to it.
|
|
1428
|
+
if audio_enabled:
|
|
1429
|
+
self._tasks.append(asyncio.create_task(self._audio_retry_loop()))
|
|
1430
|
+
try:
|
|
1431
|
+
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1432
|
+
except asyncio.TimeoutError:
|
|
1433
|
+
detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
|
|
1434
|
+
await self.agent._report_pipeline(
|
|
1435
|
+
"microphone_published", "blocked",
|
|
1436
|
+
f"Microphone track published but PCM capture did not start: {detail}",
|
|
1437
|
+
{"device": self.agent.audio_capture_device, "error": detail},
|
|
1438
|
+
)
|
|
1439
|
+
logger.warning(
|
|
1440
|
+
"microphone PCM is not ready (%s); keeping the session alive while capture retries",
|
|
1441
|
+
detail,
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
if self._capture and self.video_source:
|
|
606
1445
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
607
1446
|
|
|
608
1447
|
async def _play_remote_audio(self, track, sid: str) -> None:
|
|
609
|
-
try:
|
|
610
|
-
import pyaudio
|
|
611
|
-
except Exception as e:
|
|
612
|
-
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
613
|
-
return
|
|
614
1448
|
OUT_RATE = 48000
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1449
|
+
OUT_CHANNELS = 2
|
|
1450
|
+
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1451
|
+
pa = None
|
|
1452
|
+
output_device_index = None
|
|
1453
|
+
speaker_guard = None
|
|
1454
|
+
|
|
1455
|
+
# RoboVision inventories Linux devices through sounddevice/PortAudio,
|
|
1456
|
+
# but the numeric indices are not stable across PyAudio builds. More
|
|
1457
|
+
# importantly, BMW's production USB adapter is already proven through
|
|
1458
|
+
# ALSA's plughw conversion path. Use that exact endpoint for live TTS
|
|
1459
|
+
# instead of reopening the unrelated PyAudio index.
|
|
1460
|
+
if sys.platform.startswith("linux") and "hw:" in selected_output:
|
|
1461
|
+
import re
|
|
1462
|
+
import subprocess
|
|
1463
|
+
from media_lock import media_lock
|
|
1464
|
+
|
|
1465
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
|
|
1466
|
+
if not match:
|
|
1467
|
+
logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
|
|
1468
|
+
return
|
|
1469
|
+
alsa_device = f"plug{match.group(1)}"
|
|
1470
|
+
try:
|
|
1471
|
+
speaker_guard = media_lock("speaker", timeout=8.0).acquire()
|
|
1472
|
+
process = subprocess.Popen(
|
|
1473
|
+
[
|
|
1474
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw",
|
|
1475
|
+
"-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
|
|
1476
|
+
],
|
|
1477
|
+
stdin=subprocess.PIPE,
|
|
1478
|
+
stderr=subprocess.PIPE,
|
|
1479
|
+
)
|
|
1480
|
+
except Exception as exc:
|
|
1481
|
+
if speaker_guard is not None:
|
|
1482
|
+
speaker_guard.release()
|
|
1483
|
+
logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
|
|
1484
|
+
return
|
|
1485
|
+
if process.stdin is None:
|
|
1486
|
+
logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
|
|
1487
|
+
process.kill()
|
|
1488
|
+
process.wait(timeout=1)
|
|
1489
|
+
speaker_guard.release()
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
class _AplayOutput:
|
|
1493
|
+
def write(self, chunk: bytes) -> None:
|
|
1494
|
+
if process.poll() is not None:
|
|
1495
|
+
detail = ""
|
|
1496
|
+
if process.stderr is not None:
|
|
1497
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1498
|
+
raise OSError(detail or f"aplay exited {process.returncode}")
|
|
1499
|
+
process.stdin.write(chunk)
|
|
1500
|
+
process.stdin.flush()
|
|
1501
|
+
|
|
1502
|
+
def stop_stream(self) -> None:
|
|
1503
|
+
if process.stdin and not process.stdin.closed:
|
|
1504
|
+
process.stdin.close()
|
|
1505
|
+
try:
|
|
1506
|
+
process.wait(timeout=2)
|
|
1507
|
+
except subprocess.TimeoutExpired:
|
|
1508
|
+
process.terminate()
|
|
1509
|
+
try:
|
|
1510
|
+
process.wait(timeout=1)
|
|
1511
|
+
except subprocess.TimeoutExpired:
|
|
1512
|
+
process.kill()
|
|
1513
|
+
process.wait(timeout=1)
|
|
1514
|
+
|
|
1515
|
+
def close(self) -> None:
|
|
1516
|
+
return
|
|
1517
|
+
|
|
1518
|
+
out = _AplayOutput()
|
|
1519
|
+
output_device_index = alsa_device
|
|
1520
|
+
else:
|
|
1521
|
+
try:
|
|
1522
|
+
import pyaudio
|
|
1523
|
+
except Exception as e:
|
|
1524
|
+
logger.warning(f"pyaudio unavailable for playback: {e}")
|
|
1525
|
+
return
|
|
1526
|
+
pa = pyaudio.PyAudio()
|
|
1527
|
+
# Same MME-vs-WASAPI gotcha as mic capture (see
|
|
1528
|
+
# PyAudioCapture._resolve_device): prefer the endpoint backing the
|
|
1529
|
+
# Windows volume mixer instead of the silent MME default.
|
|
1530
|
+
if selected_output.strip().isdigit():
|
|
1531
|
+
output_device_index = int(selected_output.strip())
|
|
1532
|
+
try:
|
|
1533
|
+
if selected_output.lower() == "default":
|
|
1534
|
+
wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
|
|
1535
|
+
idx = wasapi.get("defaultOutputDevice")
|
|
1536
|
+
if idx is not None and idx >= 0:
|
|
1537
|
+
output_device_index = idx
|
|
1538
|
+
elif output_device_index is None:
|
|
1539
|
+
needle = selected_output.lower()
|
|
1540
|
+
for i in range(pa.get_device_count()):
|
|
1541
|
+
info = pa.get_device_info_by_index(i)
|
|
1542
|
+
if info.get("maxOutputChannels", 0) > 0 and needle in str(info.get("name", "")).lower():
|
|
1543
|
+
output_device_index = i
|
|
1544
|
+
break
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
logger.debug(f"audio out: WASAPI default output lookup failed, using PyAudio default: {e}")
|
|
1547
|
+
out = pa.open(
|
|
1548
|
+
format=pyaudio.paInt16, channels=OUT_CHANNELS, rate=OUT_RATE, output=True,
|
|
1549
|
+
output_device_index=output_device_index,
|
|
1550
|
+
frames_per_buffer=960,
|
|
1551
|
+
)
|
|
1552
|
+
logger.info(f"audio out: opened playback stream for {sid} (device_index={output_device_index})")
|
|
618
1553
|
frame_count = 0
|
|
1554
|
+
mismatch_logged = False
|
|
1555
|
+
try:
|
|
1556
|
+
import numpy as np
|
|
1557
|
+
except Exception as e:
|
|
1558
|
+
np = None
|
|
1559
|
+
logger.warning(f"numpy unavailable for playback resampling: {e}")
|
|
1560
|
+
|
|
1561
|
+
# Frames arrive over the network in irregular bursts (TTS streaming,
|
|
1562
|
+
# scheduling jitter); writing each one straight to a blocking PyAudio
|
|
1563
|
+
# stream inline ties the audio device's write timing to that jitter,
|
|
1564
|
+
# which is what produced "heavily distorted/staticky" playback even
|
|
1565
|
+
# with matching rate/channels. Decouple the two with a small jitter
|
|
1566
|
+
# buffer: a writer thread drains a queue into PyAudio on its own
|
|
1567
|
+
# steady pace, independent of how unevenly frames actually arrive.
|
|
1568
|
+
import queue
|
|
1569
|
+
import threading
|
|
1570
|
+
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1571
|
+
written_frames = [0]
|
|
1572
|
+
PREBUFFER_CHUNKS = 3
|
|
1573
|
+
stream_failed = threading.Event()
|
|
1574
|
+
playback_reported = threading.Event()
|
|
1575
|
+
event_loop = asyncio.get_running_loop()
|
|
1576
|
+
echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
|
|
1577
|
+
|
|
1578
|
+
def _outbound_peak(chunk: bytes) -> int:
|
|
1579
|
+
if len(chunk) < 2:
|
|
1580
|
+
return 0
|
|
1581
|
+
try:
|
|
1582
|
+
samples = memoryview(chunk).cast("h")
|
|
1583
|
+
# Stereo duplication means sampling every eighth value is
|
|
1584
|
+
# sufficient and keeps the writer thread lightweight.
|
|
1585
|
+
return max((abs(int(value)) for value in samples[::8]), default=0)
|
|
1586
|
+
except (TypeError, ValueError):
|
|
1587
|
+
return 0
|
|
1588
|
+
|
|
1589
|
+
def _open_echo_gate() -> None:
|
|
1590
|
+
if not self._half_duplex:
|
|
1591
|
+
return
|
|
1592
|
+
if not self._speaker_playback_active.is_set():
|
|
1593
|
+
self._speaker_started_at = time.monotonic()
|
|
1594
|
+
self._echo_mic_floor = 0.0
|
|
1595
|
+
self._barge_in_candidate_frames = 0
|
|
1596
|
+
self._speaker_playback_active.set()
|
|
1597
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1598
|
+
|
|
1599
|
+
def _extend_echo_gate() -> None:
|
|
1600
|
+
if self._half_duplex:
|
|
1601
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1602
|
+
|
|
1603
|
+
def _safe_write(chunk: bytes) -> bool:
|
|
1604
|
+
if stream_failed.is_set():
|
|
1605
|
+
return False
|
|
1606
|
+
try:
|
|
1607
|
+
audible = _outbound_peak(chunk) >= echo_gate_peak
|
|
1608
|
+
if audible:
|
|
1609
|
+
_open_echo_gate()
|
|
1610
|
+
elif time.monotonic() >= self._speaker_gate_until:
|
|
1611
|
+
self._speaker_playback_active.clear()
|
|
1612
|
+
out.write(chunk)
|
|
1613
|
+
if audible:
|
|
1614
|
+
_extend_echo_gate()
|
|
1615
|
+
if not playback_reported.is_set():
|
|
1616
|
+
playback_reported.set()
|
|
1617
|
+
asyncio.run_coroutine_threadsafe(
|
|
1618
|
+
self.agent._report_pipeline(
|
|
1619
|
+
"playback_started", "ok", "First TTS audio chunk written to robot speaker"
|
|
1620
|
+
),
|
|
1621
|
+
event_loop,
|
|
1622
|
+
)
|
|
1623
|
+
return True
|
|
1624
|
+
except Exception as e:
|
|
1625
|
+
stream_failed.set()
|
|
1626
|
+
logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
|
|
1627
|
+
return False
|
|
1628
|
+
|
|
1629
|
+
def _writer():
|
|
1630
|
+
buffered = []
|
|
1631
|
+
started = False
|
|
1632
|
+
while True:
|
|
1633
|
+
try:
|
|
1634
|
+
chunk = write_queue.get()
|
|
1635
|
+
except queue.Empty:
|
|
1636
|
+
continue
|
|
1637
|
+
if chunk is None:
|
|
1638
|
+
# Drain any short tail when the remote track ends.
|
|
1639
|
+
for pending in buffered:
|
|
1640
|
+
if not _safe_write(pending):
|
|
1641
|
+
break
|
|
1642
|
+
break
|
|
1643
|
+
if not started:
|
|
1644
|
+
buffered.append(chunk)
|
|
1645
|
+
if len(buffered) < PREBUFFER_CHUNKS:
|
|
1646
|
+
continue
|
|
1647
|
+
for pending in buffered:
|
|
1648
|
+
if not _safe_write(pending):
|
|
1649
|
+
break
|
|
1650
|
+
buffered.clear()
|
|
1651
|
+
if stream_failed.is_set():
|
|
1652
|
+
break
|
|
1653
|
+
started = True
|
|
1654
|
+
continue
|
|
1655
|
+
try:
|
|
1656
|
+
if _safe_write(chunk):
|
|
1657
|
+
written_frames[0] += 1
|
|
1658
|
+
if written_frames[0] == 1:
|
|
1659
|
+
logger.info(f"audio out: writer thread wrote its first chunk for {sid}")
|
|
1660
|
+
elif stream_failed.is_set():
|
|
1661
|
+
break
|
|
1662
|
+
except Exception as e:
|
|
1663
|
+
# A single write failing (e.g. a transient device hiccup) must
|
|
1664
|
+
# not silently kill the whole thread — that leaves every
|
|
1665
|
+
# later frame queued with nothing left to consume them,
|
|
1666
|
+
# which looks exactly like "no audio at all" even though
|
|
1667
|
+
# frames kept arriving fine. Log loudly and keep going.
|
|
1668
|
+
logger.warning(f"audio out write error (continuing): {e}")
|
|
1669
|
+
|
|
1670
|
+
writer_thread = threading.Thread(target=_writer, daemon=True)
|
|
1671
|
+
writer_thread.start()
|
|
1672
|
+
|
|
619
1673
|
try:
|
|
620
1674
|
stream = self.rtc.AudioStream(track=track)
|
|
621
1675
|
async for frame in stream:
|
|
622
1676
|
af = frame.frame if hasattr(frame, "frame") else frame
|
|
623
|
-
data = bytes(af.data)
|
|
624
1677
|
frame_count += 1
|
|
625
1678
|
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}"
|
|
1679
|
+
logger.info(
|
|
1680
|
+
f"audio out: first frame received for {sid} "
|
|
1681
|
+
f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
|
|
633
1682
|
)
|
|
634
|
-
|
|
1683
|
+
in_rate = af.sample_rate
|
|
1684
|
+
in_channels = int(getattr(af, "num_channels", 1) or 1)
|
|
1685
|
+
raw = bytes(af.data)
|
|
1686
|
+
if np is not None:
|
|
1687
|
+
in_data = np.frombuffer(raw, dtype=np.int16)
|
|
1688
|
+
if in_channels > 1:
|
|
1689
|
+
in_data = in_data.reshape(-1, in_channels).mean(axis=1).astype(np.int16)
|
|
1690
|
+
else:
|
|
1691
|
+
in_data = raw
|
|
1692
|
+
|
|
1693
|
+
if in_rate == OUT_RATE or np is None:
|
|
1694
|
+
if in_rate != OUT_RATE and not mismatch_logged:
|
|
1695
|
+
mismatch_logged = True
|
|
1696
|
+
logger.warning(
|
|
1697
|
+
f"audio out: sample rate {in_rate} != {OUT_RATE} and numpy "
|
|
1698
|
+
f"unavailable — playing unresampled (expect distortion)"
|
|
1699
|
+
)
|
|
1700
|
+
mono_data = in_data
|
|
1701
|
+
else:
|
|
1702
|
+
# Mirror the real Pi client's resampling (pi-client/livekit_bridge.py):
|
|
1703
|
+
# writing raw bytes straight to a fixed-rate output stream when the
|
|
1704
|
+
# source rate differs (e.g. Kokoro's native rate vs our 48kHz stream)
|
|
1705
|
+
# is what produced the "heavily distorted/staticky" playback — every
|
|
1706
|
+
# frame needs resampling to OUT_RATE first, not just the ones that
|
|
1707
|
+
# happen to already match.
|
|
1708
|
+
if not mismatch_logged:
|
|
1709
|
+
mismatch_logged = True
|
|
1710
|
+
logger.info(f"audio out: resampling {in_rate}Hz -> {OUT_RATE}Hz for {sid}")
|
|
1711
|
+
ratio = OUT_RATE / in_rate
|
|
1712
|
+
n_out = int(len(in_data) * ratio)
|
|
1713
|
+
indices = np.linspace(0, len(in_data) - 1, n_out)
|
|
1714
|
+
out_data = np.interp(indices, np.arange(len(in_data)), in_data.astype(np.float64)).astype(np.int16)
|
|
1715
|
+
mono_data = out_data
|
|
1716
|
+
|
|
1717
|
+
if np is not None:
|
|
1718
|
+
# The Windows endpoint is stereo; duplicate mono LiveKit audio explicitly.
|
|
1719
|
+
data = np.repeat(mono_data[:, None], OUT_CHANNELS, axis=1).astype(np.int16).tobytes()
|
|
1720
|
+
else:
|
|
1721
|
+
data = b"".join(raw[i:i + 2] * OUT_CHANNELS for i in range(0, len(raw), 2))
|
|
1722
|
+
write_queue.put(data)
|
|
635
1723
|
except Exception as e:
|
|
636
1724
|
logger.warning(f"audio out stream error: {e}")
|
|
637
1725
|
finally:
|
|
638
|
-
|
|
1726
|
+
cancelling = bool(asyncio.current_task() and asyncio.current_task().cancelling())
|
|
1727
|
+
if cancelling:
|
|
1728
|
+
# On publisher teardown, close ALSA first. Waiting for the
|
|
1729
|
+
# writer while aplay still owns the device leaves hw:X,Y busy
|
|
1730
|
+
# long enough for the queued dashboard test to fail.
|
|
1731
|
+
stream_failed.set()
|
|
1732
|
+
out.stop_stream()
|
|
1733
|
+
write_queue.put(None)
|
|
1734
|
+
writer_thread.join(timeout=2.0)
|
|
1735
|
+
logger.info(
|
|
1736
|
+
f"audio out: {sid} received {frame_count} frames, "
|
|
1737
|
+
f"writer wrote {written_frames[0]} chunks, queue backlog at close={write_queue.qsize()}"
|
|
1738
|
+
)
|
|
1739
|
+
if not cancelling:
|
|
1740
|
+
out.stop_stream()
|
|
639
1741
|
out.close()
|
|
640
|
-
pa
|
|
1742
|
+
if pa is not None:
|
|
1743
|
+
pa.terminate()
|
|
1744
|
+
if speaker_guard is not None:
|
|
1745
|
+
speaker_guard.release()
|
|
1746
|
+
if self._half_duplex:
|
|
1747
|
+
self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
|
|
1748
|
+
self._speaker_playback_active.clear()
|
|
641
1749
|
|
|
642
1750
|
async def stop(self) -> None:
|
|
643
1751
|
self._stop_event.set()
|
|
@@ -648,6 +1756,9 @@ class LiveKitPublisher:
|
|
|
648
1756
|
except asyncio.CancelledError:
|
|
649
1757
|
pass
|
|
650
1758
|
self._tasks.clear()
|
|
1759
|
+
if self._mic_capture:
|
|
1760
|
+
await asyncio.to_thread(self._mic_capture.stop)
|
|
1761
|
+
self._mic_capture = None
|
|
651
1762
|
if self._capture:
|
|
652
1763
|
self._capture.stop()
|
|
653
1764
|
self._capture = None
|
|
@@ -669,6 +1780,7 @@ class LiveKitPublisher:
|
|
|
669
1780
|
frame = await asyncio.to_thread(self._capture.read)
|
|
670
1781
|
if frame is not None:
|
|
671
1782
|
self.video_source.capture_frame(frame)
|
|
1783
|
+
self._detect_motion(frame)
|
|
672
1784
|
elapsed = time.monotonic() - start
|
|
673
1785
|
sleep_for = frame_interval - elapsed
|
|
674
1786
|
if sleep_for > 0:
|
|
@@ -677,12 +1789,65 @@ class LiveKitPublisher:
|
|
|
677
1789
|
except asyncio.TimeoutError:
|
|
678
1790
|
pass
|
|
679
1791
|
|
|
1792
|
+
def _detect_motion(self, frame: "rtc.VideoFrame") -> None:
|
|
1793
|
+
"""Detect motion from the already-published camera frame.
|
|
1794
|
+
|
|
1795
|
+
The preview publisher is the sole camera owner. Keeping motion
|
|
1796
|
+
detection here avoids opening the Windows camera a second time from a
|
|
1797
|
+
separate detector process, which caused black frames and crashes.
|
|
1798
|
+
"""
|
|
1799
|
+
now = time.monotonic()
|
|
1800
|
+
if now - self._last_motion_sample < 0.25:
|
|
1801
|
+
return
|
|
1802
|
+
self._last_motion_sample = now
|
|
1803
|
+
try:
|
|
1804
|
+
import numpy as np
|
|
1805
|
+
|
|
1806
|
+
data = np.frombuffer(bytes(frame.data), dtype=np.uint8)
|
|
1807
|
+
sample = data.reshape(frame.height, frame.width, 3)[::12, ::12].mean(axis=2)
|
|
1808
|
+
previous = self._motion_reference
|
|
1809
|
+
self._motion_reference = sample
|
|
1810
|
+
if previous is None or previous.shape != sample.shape:
|
|
1811
|
+
return
|
|
1812
|
+
change = float(np.abs(sample - previous).mean())
|
|
1813
|
+
if change >= float(os.getenv("VISION_MOTION_THRESHOLD", "12")):
|
|
1814
|
+
asyncio.create_task(
|
|
1815
|
+
self._on_vision_motion({"source": "preview_camera", "change": change})
|
|
1816
|
+
)
|
|
1817
|
+
except Exception as e:
|
|
1818
|
+
logger.debug(f"preview motion sampling failed: {e}")
|
|
1819
|
+
|
|
1820
|
+
async def _audio_retry_loop(self) -> None:
|
|
1821
|
+
"""Keep microphone capture alive across transient ALSA ownership errors."""
|
|
1822
|
+
while not self._stop_event.is_set():
|
|
1823
|
+
try:
|
|
1824
|
+
await self._audio_loop()
|
|
1825
|
+
except asyncio.CancelledError:
|
|
1826
|
+
raise
|
|
1827
|
+
except Exception as e:
|
|
1828
|
+
self._mic_error = str(e)
|
|
1829
|
+
logger.warning(
|
|
1830
|
+
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1831
|
+
)
|
|
1832
|
+
finally:
|
|
1833
|
+
mic = self._mic_capture
|
|
1834
|
+
self._mic_capture = None
|
|
1835
|
+
if mic is not None:
|
|
1836
|
+
await asyncio.to_thread(mic.stop)
|
|
1837
|
+
if not self._stop_event.is_set():
|
|
1838
|
+
try:
|
|
1839
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=1.0)
|
|
1840
|
+
except asyncio.TimeoutError:
|
|
1841
|
+
pass
|
|
1842
|
+
|
|
680
1843
|
async def _audio_loop(self) -> None:
|
|
681
1844
|
assert self.audio_source is not None
|
|
682
|
-
mic = create_audio_capture(self.agent.
|
|
1845
|
+
mic = create_audio_capture(self.agent.audio_capture_device)
|
|
683
1846
|
if mic is None:
|
|
684
1847
|
logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
|
|
685
1848
|
return
|
|
1849
|
+
self._mic_capture = mic
|
|
1850
|
+
self._mic_error = None
|
|
686
1851
|
logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
|
|
687
1852
|
# Read in bigger batches (100ms) instead of one 20ms frame per
|
|
688
1853
|
# asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
|
|
@@ -698,12 +1863,13 @@ class LiveKitPublisher:
|
|
|
698
1863
|
samples_per_frame = int(48000 * frame_ms / 1000)
|
|
699
1864
|
samples_per_batch = int(48000 * BATCH_MS / 1000)
|
|
700
1865
|
bytes_per_frame = samples_per_frame * 2 # int16 mono
|
|
701
|
-
|
|
1866
|
+
mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
|
|
702
1867
|
_diag_peak = 0
|
|
703
1868
|
_diag_count = 0
|
|
704
1869
|
_diag_last_log = time.monotonic()
|
|
705
1870
|
_diag_read_ms = 0.0
|
|
706
1871
|
_diag_publish_ms = 0.0
|
|
1872
|
+
_echo_gate_was_active = False
|
|
707
1873
|
batch_interval = BATCH_MS / 1000
|
|
708
1874
|
while not self._stop_event.is_set():
|
|
709
1875
|
_iter_start = time.monotonic()
|
|
@@ -715,11 +1881,63 @@ class LiveKitPublisher:
|
|
|
715
1881
|
_pub_start = time.monotonic()
|
|
716
1882
|
for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
|
|
717
1883
|
chunk = data[off:off + bytes_per_frame]
|
|
1884
|
+
now = time.monotonic()
|
|
1885
|
+
echo_gate_active = self._half_duplex and (
|
|
1886
|
+
self._speaker_playback_active.is_set()
|
|
1887
|
+
or now < self._speaker_gate_until
|
|
1888
|
+
)
|
|
1889
|
+
# Learn the microphone's speaker-echo floor while output is
|
|
1890
|
+
# active. A nearby visitor speaking produces a fast peak well
|
|
1891
|
+
# above that floor; reopen the mic briefly so LiveKit VAD can
|
|
1892
|
+
# cancel normal TTS. The initial 300 ms remains protected.
|
|
1893
|
+
_, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
|
|
1894
|
+
if echo_gate_active and self._adaptive_barge_in:
|
|
1895
|
+
if self._echo_mic_floor <= 0:
|
|
1896
|
+
self._echo_mic_floor = float(raw_peak)
|
|
1897
|
+
else:
|
|
1898
|
+
self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
|
|
1899
|
+
threshold = max(
|
|
1900
|
+
self._barge_in_min_peak,
|
|
1901
|
+
int(self._echo_mic_floor * self._barge_in_ratio),
|
|
1902
|
+
)
|
|
1903
|
+
warmed = now - self._speaker_started_at >= 0.3
|
|
1904
|
+
if warmed and raw_peak >= threshold:
|
|
1905
|
+
self._barge_in_candidate_frames += 1
|
|
1906
|
+
else:
|
|
1907
|
+
self._barge_in_candidate_frames = 0
|
|
1908
|
+
if self._barge_in_candidate_frames >= 3:
|
|
1909
|
+
if now >= self._barge_in_until:
|
|
1910
|
+
logger.info(
|
|
1911
|
+
"audio_loop: adaptive barge-in opened mic "
|
|
1912
|
+
"(peak=%d threshold=%d echo_floor=%d)",
|
|
1913
|
+
raw_peak, threshold, int(self._echo_mic_floor),
|
|
1914
|
+
)
|
|
1915
|
+
self._barge_in_until = now + self._barge_in_hold
|
|
1916
|
+
barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
|
|
1917
|
+
if echo_gate_active and not barge_in_active:
|
|
1918
|
+
# Preserve 20 ms frame cadence; only suppress content.
|
|
1919
|
+
# Stopping publication would create gaps and destabilize
|
|
1920
|
+
# VAD/endpointing when listening resumes.
|
|
1921
|
+
chunk = b"\x00" * len(chunk)
|
|
1922
|
+
p = 0
|
|
1923
|
+
else:
|
|
1924
|
+
chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
|
|
1925
|
+
if echo_gate_active != _echo_gate_was_active:
|
|
1926
|
+
logger.info(
|
|
1927
|
+
"audio_loop: speaker echo gate %s",
|
|
1928
|
+
"active" if echo_gate_active else "released",
|
|
1929
|
+
)
|
|
1930
|
+
_echo_gate_was_active = echo_gate_active
|
|
718
1931
|
frame = self.rtc.AudioFrame(
|
|
719
1932
|
data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
|
|
720
1933
|
)
|
|
721
1934
|
await self.audio_source.capture_frame(frame)
|
|
722
|
-
|
|
1935
|
+
if not self._mic_streaming.is_set():
|
|
1936
|
+
self._mic_streaming.set()
|
|
1937
|
+
await self.agent._report_pipeline(
|
|
1938
|
+
"microphone_published", "ok",
|
|
1939
|
+
"Microphone track published with live PCM frames",
|
|
1940
|
+
)
|
|
723
1941
|
_diag_peak = max(_diag_peak, p)
|
|
724
1942
|
_diag_count += 1
|
|
725
1943
|
_diag_read_ms += (_t1 - _t0) * 1000
|
|
@@ -764,10 +1982,22 @@ class OpencvVideoCapture(VideoCapture):
|
|
|
764
1982
|
self.cv2 = cv2
|
|
765
1983
|
if isinstance(device, str) and device.startswith("/dev/video"):
|
|
766
1984
|
device = int(device.replace("/dev/video", ""))
|
|
767
|
-
|
|
1985
|
+
elif isinstance(device, str) and device.isdigit():
|
|
1986
|
+
device = int(device)
|
|
1987
|
+
# MSMF frequently returns intermittent grab failures for USB cameras
|
|
1988
|
+
# on Windows. DirectShow is more stable for this long-lived stream;
|
|
1989
|
+
# retain the default backend as a fallback for unusual devices.
|
|
1990
|
+
if os.name == "nt" and isinstance(device, int):
|
|
1991
|
+
self.cap = cv2.VideoCapture(device, cv2.CAP_DSHOW)
|
|
1992
|
+
if not self.cap.isOpened():
|
|
1993
|
+
self.cap.release()
|
|
1994
|
+
self.cap = cv2.VideoCapture(device)
|
|
1995
|
+
else:
|
|
1996
|
+
self.cap = cv2.VideoCapture(device)
|
|
768
1997
|
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
|
|
769
1998
|
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
|
|
770
1999
|
self.cap.set(cv2.CAP_PROP_FPS, fps)
|
|
2000
|
+
self._last_black_log = 0.0
|
|
771
2001
|
self._ensure_frame()
|
|
772
2002
|
|
|
773
2003
|
def _ensure_frame(self):
|
|
@@ -780,11 +2010,28 @@ class OpencvVideoCapture(VideoCapture):
|
|
|
780
2010
|
ok, bgr = self.cap.read()
|
|
781
2011
|
if not ok or bgr is None:
|
|
782
2012
|
return None
|
|
2013
|
+
# OpenCV can report a successful read while a Windows camera driver
|
|
2014
|
+
# returns an effectively black frame (privacy shutter, wrong device,
|
|
2015
|
+
# or a stale DirectShow handle). Do not publish misleading video.
|
|
2016
|
+
mean = float(bgr.mean())
|
|
2017
|
+
if mean <= 1.0 and float(bgr.max()) <= 8.0:
|
|
2018
|
+
now = time.monotonic()
|
|
2019
|
+
if now - self._last_black_log >= 10.0:
|
|
2020
|
+
logger.error(
|
|
2021
|
+
"camera returned black frames from device %s; check camera selection, "
|
|
2022
|
+
"privacy shutter, and Windows camera permissions",
|
|
2023
|
+
self.cap,
|
|
2024
|
+
)
|
|
2025
|
+
self._last_black_log = now
|
|
2026
|
+
return None
|
|
2027
|
+
# The current LiveKit RTC SDK exposes RGB/RGBA frame types, not BGR.
|
|
2028
|
+
# OpenCV captures BGR, so convert before constructing the frame.
|
|
2029
|
+
rgb = self.cv2.cvtColor(bgr, self.cv2.COLOR_BGR2RGB)
|
|
783
2030
|
return rtc.VideoFrame(
|
|
784
|
-
width=
|
|
785
|
-
height=
|
|
786
|
-
type=rtc.VideoBufferType.
|
|
787
|
-
data=
|
|
2031
|
+
width=rgb.shape[1],
|
|
2032
|
+
height=rgb.shape[0],
|
|
2033
|
+
type=rtc.VideoBufferType.RGB24,
|
|
2034
|
+
data=rgb.tobytes(),
|
|
788
2035
|
)
|
|
789
2036
|
|
|
790
2037
|
def stop(self):
|
|
@@ -812,7 +2059,7 @@ class Picamera2Capture(VideoCapture):
|
|
|
812
2059
|
return rtc.VideoFrame(
|
|
813
2060
|
width=self.width,
|
|
814
2061
|
height=self.height,
|
|
815
|
-
type=rtc.VideoBufferType.
|
|
2062
|
+
type=rtc.VideoBufferType.RGB24,
|
|
816
2063
|
data=arr.tobytes(),
|
|
817
2064
|
)
|
|
818
2065
|
|
|
@@ -823,7 +2070,8 @@ class Picamera2Capture(VideoCapture):
|
|
|
823
2070
|
pass
|
|
824
2071
|
|
|
825
2072
|
|
|
826
|
-
def create_video_capture(device: str, width: int, height: int, fps: int
|
|
2073
|
+
def create_video_capture(device: str, width: int, height: int, fps: int,
|
|
2074
|
+
robovision_url: Optional[str] = None) -> Optional[VideoCapture]:
|
|
827
2075
|
if device.lower() in ("none", "", "false", "null"):
|
|
828
2076
|
return None
|
|
829
2077
|
try:
|
|
@@ -833,6 +2081,16 @@ def create_video_capture(device: str, width: int, height: int, fps: int) -> Opti
|
|
|
833
2081
|
logger.error(f"livekit python sdk not installed: {e}")
|
|
834
2082
|
return None
|
|
835
2083
|
|
|
2084
|
+
if robovision_url:
|
|
2085
|
+
stream_url = f"{robovision_url.rstrip('/')}/video_feed"
|
|
2086
|
+
try:
|
|
2087
|
+
cap = OpencvVideoCapture(stream_url, width, height, fps)
|
|
2088
|
+
logger.info(f"using RoboVisionAI_PI camera stream {stream_url}")
|
|
2089
|
+
return cap
|
|
2090
|
+
except Exception as e:
|
|
2091
|
+
logger.error(f"RoboVision camera stream unavailable: {e}")
|
|
2092
|
+
return None
|
|
2093
|
+
|
|
836
2094
|
# Auto-detect: prefer first V4L2 device, fall back to picamera2 if available.
|
|
837
2095
|
if device.lower() in ("auto", "default", "first"):
|
|
838
2096
|
for i in range(4):
|
|
@@ -870,6 +2128,112 @@ class AudioCapture:
|
|
|
870
2128
|
raise NotImplementedError
|
|
871
2129
|
|
|
872
2130
|
|
|
2131
|
+
class AlsaAudioCapture(AudioCapture):
|
|
2132
|
+
"""Capture Linux PCM through the same ALSA path used by onsite tests."""
|
|
2133
|
+
|
|
2134
|
+
def __init__(self, device: str):
|
|
2135
|
+
import os
|
|
2136
|
+
import re
|
|
2137
|
+
import select
|
|
2138
|
+
import subprocess
|
|
2139
|
+
from media_lock import media_lock
|
|
2140
|
+
|
|
2141
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
2142
|
+
if not match:
|
|
2143
|
+
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
2144
|
+
self.buffer = bytearray()
|
|
2145
|
+
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
2146
|
+
self.process = None
|
|
2147
|
+
self.source_rate = 48000
|
|
2148
|
+
errors = []
|
|
2149
|
+
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
2150
|
+
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
2151
|
+
# accept that rate and resample below rather than publishing silence.
|
|
2152
|
+
for source_rate in (48000, 44100):
|
|
2153
|
+
alsa_device = f"plug{match.group(1)}"
|
|
2154
|
+
process = None
|
|
2155
|
+
try:
|
|
2156
|
+
process = subprocess.Popen(
|
|
2157
|
+
[
|
|
2158
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
2159
|
+
"-f", "S16_LE", "-r", str(source_rate), "-c", "1",
|
|
2160
|
+
"--period-size", str(max(256, source_rate // 50)),
|
|
2161
|
+
],
|
|
2162
|
+
stdout=subprocess.PIPE,
|
|
2163
|
+
stderr=subprocess.PIPE,
|
|
2164
|
+
bufsize=0,
|
|
2165
|
+
)
|
|
2166
|
+
if process.stdout is None:
|
|
2167
|
+
raise RuntimeError("arecord did not provide a PCM stream")
|
|
2168
|
+
ready, _, _ = select.select([process.stdout], [], [], 2.0)
|
|
2169
|
+
if not ready:
|
|
2170
|
+
if process.poll() is None:
|
|
2171
|
+
raise TimeoutError("arecord produced no PCM within 2 seconds")
|
|
2172
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2173
|
+
raise OSError(detail or f"arecord exited {process.returncode}")
|
|
2174
|
+
first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
|
|
2175
|
+
if not first:
|
|
2176
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2177
|
+
raise OSError(detail or "arecord returned an empty PCM frame")
|
|
2178
|
+
self.process = process
|
|
2179
|
+
self.source_rate = source_rate
|
|
2180
|
+
self.alsa_device = alsa_device
|
|
2181
|
+
if source_rate != 48000:
|
|
2182
|
+
first = _pcm16_resample_mono(first, source_rate, 48000)
|
|
2183
|
+
self.buffer.extend(first)
|
|
2184
|
+
break
|
|
2185
|
+
except Exception as exc:
|
|
2186
|
+
errors.append(f"{source_rate}Hz: {exc}")
|
|
2187
|
+
if process is not None:
|
|
2188
|
+
if process.poll() is None:
|
|
2189
|
+
process.terminate()
|
|
2190
|
+
try:
|
|
2191
|
+
process.wait(timeout=1)
|
|
2192
|
+
except subprocess.TimeoutExpired:
|
|
2193
|
+
process.kill()
|
|
2194
|
+
process.wait(timeout=1)
|
|
2195
|
+
if self.process is None:
|
|
2196
|
+
self.media_guard.release()
|
|
2197
|
+
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
2198
|
+
|
|
2199
|
+
def read(self, samples_per_frame: int):
|
|
2200
|
+
import os
|
|
2201
|
+
from livekit import rtc
|
|
2202
|
+
|
|
2203
|
+
bytes_needed = samples_per_frame * 2
|
|
2204
|
+
source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
|
|
2205
|
+
while len(self.buffer) < bytes_needed:
|
|
2206
|
+
chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
|
|
2207
|
+
if not chunk:
|
|
2208
|
+
detail = ""
|
|
2209
|
+
if self.process.stderr is not None:
|
|
2210
|
+
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
2211
|
+
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2212
|
+
if self.source_rate != 48000:
|
|
2213
|
+
chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
|
|
2214
|
+
self.buffer.extend(chunk)
|
|
2215
|
+
data = bytes(self.buffer[:bytes_needed])
|
|
2216
|
+
del self.buffer[:bytes_needed]
|
|
2217
|
+
return rtc.AudioFrame(
|
|
2218
|
+
data=data,
|
|
2219
|
+
sample_rate=48000,
|
|
2220
|
+
num_channels=1,
|
|
2221
|
+
samples_per_channel=samples_per_frame,
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
def stop(self):
|
|
2225
|
+
try:
|
|
2226
|
+
if self.process.poll() is None:
|
|
2227
|
+
self.process.terminate()
|
|
2228
|
+
try:
|
|
2229
|
+
self.process.wait(timeout=2)
|
|
2230
|
+
except Exception:
|
|
2231
|
+
self.process.kill()
|
|
2232
|
+
self.process.wait(timeout=1)
|
|
2233
|
+
finally:
|
|
2234
|
+
self.media_guard.release()
|
|
2235
|
+
|
|
2236
|
+
|
|
873
2237
|
class PyAudioCapture(AudioCapture):
|
|
874
2238
|
def __init__(self, device: str | int | None):
|
|
875
2239
|
import pyaudio
|
|
@@ -890,6 +2254,8 @@ class PyAudioCapture(AudioCapture):
|
|
|
890
2254
|
def _resolve_device(self, device: str | int | None) -> Optional[int]:
|
|
891
2255
|
if isinstance(device, int):
|
|
892
2256
|
return device
|
|
2257
|
+
if isinstance(device, str) and device.strip().isdigit():
|
|
2258
|
+
return int(device.strip())
|
|
893
2259
|
if device is None or device == "default":
|
|
894
2260
|
# PyAudio's global default resolves through the MME host API,
|
|
895
2261
|
# which on Windows can silently capture pure silence (no error,
|
|
@@ -950,6 +2316,12 @@ def has_audio() -> bool:
|
|
|
950
2316
|
def create_audio_capture(device: str) -> Optional[AudioCapture]:
|
|
951
2317
|
if device.lower() in ("none", "", "false", "null"):
|
|
952
2318
|
return None
|
|
2319
|
+
if sys.platform.startswith("linux") and "hw:" in device:
|
|
2320
|
+
try:
|
|
2321
|
+
return AlsaAudioCapture(device)
|
|
2322
|
+
except Exception as e:
|
|
2323
|
+
logger.warning(f"ALSA capture unavailable for {device}: {e}")
|
|
2324
|
+
raise
|
|
953
2325
|
try:
|
|
954
2326
|
import pyaudio # noqa: F401
|
|
955
2327
|
return PyAudioCapture(device if device != "default" else None)
|
|
@@ -971,6 +2343,8 @@ def main() -> None:
|
|
|
971
2343
|
parser.add_argument("--enrollment-token", default=os.getenv("ENROLLMENT_TOKEN"))
|
|
972
2344
|
parser.add_argument("--video-device", default=os.getenv("VIDEO_DEVICE", "auto"))
|
|
973
2345
|
parser.add_argument("--audio-device", default=os.getenv("AUDIO_DEVICE", "default"))
|
|
2346
|
+
parser.add_argument("--robovision-url", default=None,
|
|
2347
|
+
help="use RoboVisionAI_PI's /video_feed as the LiveKit camera source")
|
|
974
2348
|
parser.add_argument("--width", type=int, default=int(os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
975
2349
|
parser.add_argument("--height", type=int, default=int(os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
976
2350
|
parser.add_argument("--fps", type=int, default=int(os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -994,6 +2368,10 @@ def main() -> None:
|
|
|
994
2368
|
"robot_id": args.robot_id,
|
|
995
2369
|
"video_device": args.video_device,
|
|
996
2370
|
"audio_device": args.audio_device,
|
|
2371
|
+
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
2372
|
+
"use_robovision_camera": bool(args.robovision_url) or os.getenv(
|
|
2373
|
+
"ROBOVISION_CAMERA", "true"
|
|
2374
|
+
).lower() in ("1", "true", "yes", "on"),
|
|
997
2375
|
"video_width": args.width,
|
|
998
2376
|
"video_height": args.height,
|
|
999
2377
|
"video_fps": args.fps,
|