infinicode 2.8.94 → 2.8.96
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.
|
@@ -83,6 +83,10 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
83
83
|
const children = [];
|
|
84
84
|
let stopping = false;
|
|
85
85
|
let recycling = false;
|
|
86
|
+
const requestedVideo = String(opts.videoDevice ?? '').trim();
|
|
87
|
+
const hardwareVideo = !requestedVideo || ['auto', 'default', 'first'].includes(requestedVideo.toLowerCase())
|
|
88
|
+
? '/dev/video0'
|
|
89
|
+
: requestedVideo;
|
|
86
90
|
const infinicode = await resolveInfinicodeBin();
|
|
87
91
|
if (!infinicode)
|
|
88
92
|
throw new Error('could not resolve infinicode CLI');
|
|
@@ -100,14 +104,15 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
100
104
|
args: [cli, 'vision-agent', '--foreground', '--port', '5000',
|
|
101
105
|
'--motion-webhook-url', 'http://127.0.0.1:5057/'],
|
|
102
106
|
env: {
|
|
103
|
-
ROBOPARK_CAMERA_DEVICE:
|
|
107
|
+
ROBOPARK_CAMERA_DEVICE: hardwareVideo,
|
|
104
108
|
},
|
|
105
109
|
});
|
|
106
110
|
}
|
|
107
111
|
const previewArgs = [cli, 'preview-agent', '--foreground',
|
|
108
112
|
'--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
|
|
109
|
-
'--video-device',
|
|
113
|
+
'--video-device', hardwareVideo,
|
|
110
114
|
'--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio (hw:3,0)',
|
|
115
|
+
'--robovision-url', 'http://127.0.0.1:5000',
|
|
111
116
|
'--save-config'];
|
|
112
117
|
if (opts.enrollmentToken)
|
|
113
118
|
previewArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
@@ -127,6 +132,9 @@ export async function roboparkRobotRuntime(opts) {
|
|
|
127
132
|
? 'http://127.0.0.1:5000/api/media/inventory'
|
|
128
133
|
: '',
|
|
129
134
|
ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
|
|
135
|
+
// Motion remains enabled, but it reads RoboVision's shared stream. Only
|
|
136
|
+
// RoboVision may open the physical V4L2 device.
|
|
137
|
+
LOCAL_CAMERA_MOTION: 'true',
|
|
130
138
|
// Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
|
|
131
139
|
// from these USB product labels on every inventory refresh.
|
|
132
140
|
ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
|
|
@@ -150,3 +150,25 @@ Required result: `playback_started ok`.
|
|
|
150
150
|
and microphone publication, TTS subscription, and first robot playback.
|
|
151
151
|
- An unattended readiness pass does not claim an STT/LLM turn occurred. That
|
|
152
152
|
semantic turn still requires injected speech or a person near the robot.
|
|
153
|
+
|
|
154
|
+
## Camera Ownership Contract (2.8.95+)
|
|
155
|
+
|
|
156
|
+
- RoboVision is the only process permitted to open the physical V4L2 camera.
|
|
157
|
+
- Dashboard, motion sampling, and conversation video consume RoboVision's
|
|
158
|
+
shared MJPEG stream; they never open `/dev/video0` independently.
|
|
159
|
+
- Local camera motion remains enabled by default. Production mode controls
|
|
160
|
+
whether motion creates a conversation, not whether the camera worker runs.
|
|
161
|
+
- `auto`, `default`, and `first` resolve to `/dev/robopark-camera` when present,
|
|
162
|
+
otherwise `/dev/video0`; those literal strings are never passed to OpenCV.
|
|
163
|
+
|
|
164
|
+
## Audio Ownership Contract (2.8.96+)
|
|
165
|
+
|
|
166
|
+
- Fleet microphone selection is resolved by stable USB product label on every
|
|
167
|
+
inventory refresh. The current stack maps microphone to `hw:3,0` and speaker
|
|
168
|
+
to `hw:2,0`.
|
|
169
|
+
- A LiveKit microphone track is healthy only after the robot captures and
|
|
170
|
+
publishes real PCM. Publishing an empty track is not readiness.
|
|
171
|
+
- Linux capture probes `plughw` at 48 kHz, falls back to 44.1 kHz with
|
|
172
|
+
resampling, and releases the microphone lock after every failed attempt.
|
|
173
|
+
- Pipeline failures include the selected device and exact ALSA startup error.
|
|
174
|
+
A flat browser meter means missing PCM, not a quiet room.
|
package/package.json
CHANGED
|
@@ -486,7 +486,7 @@ class PreviewAgent:
|
|
|
486
486
|
# to its hardware name before opening any playback stream.
|
|
487
487
|
self.audio_playback_device = self.audio_output_device
|
|
488
488
|
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
489
|
-
self.use_robovision_camera = bool(cfg.get("use_robovision_camera",
|
|
489
|
+
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", True))
|
|
490
490
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
491
491
|
self.height = int(cfg.get("video_height", os.getenv("VIDEO_HEIGHT", DEFAULT_VIDEO_HEIGHT)))
|
|
492
492
|
self.fps = int(cfg.get("video_fps", os.getenv("VIDEO_FPS", DEFAULT_FPS)))
|
|
@@ -522,7 +522,8 @@ class PreviewAgent:
|
|
|
522
522
|
self._speaker_operation_lock = asyncio.Lock()
|
|
523
523
|
self._vision_hard_deadline: float = 0.0
|
|
524
524
|
self._vision_last_activity: float = 0.0
|
|
525
|
-
self.production_mode = False
|
|
525
|
+
self.production_mode = False
|
|
526
|
+
self._robovision_motion_state: Optional[bool] = None
|
|
526
527
|
self._remote_session_ended = False
|
|
527
528
|
self._last_device_config_poll = 0.0
|
|
528
529
|
self._motion_reference = None
|
|
@@ -632,9 +633,13 @@ class PreviewAgent:
|
|
|
632
633
|
if capture is not None:
|
|
633
634
|
await asyncio.to_thread(capture.stop)
|
|
634
635
|
|
|
635
|
-
async def _motion_loop(self) -> None:
|
|
636
|
-
"""
|
|
637
|
-
while not self._shutdown.is_set():
|
|
636
|
+
async def _motion_loop(self) -> None:
|
|
637
|
+
"""Detect motion from RoboVision's shared stream without owning V4L2."""
|
|
638
|
+
while not self._shutdown.is_set():
|
|
639
|
+
if not self.production_mode:
|
|
640
|
+
await self._stop_motion_capture()
|
|
641
|
+
await asyncio.sleep(0.5)
|
|
642
|
+
continue
|
|
638
643
|
if self._vision_session_id or self._current_state.active:
|
|
639
644
|
await self._stop_motion_capture()
|
|
640
645
|
await asyncio.sleep(0.25)
|
|
@@ -643,16 +648,17 @@ class PreviewAgent:
|
|
|
643
648
|
async with self._motion_capture_lock:
|
|
644
649
|
if self._motion_capture is None:
|
|
645
650
|
self._motion_capture = await asyncio.to_thread(
|
|
646
|
-
create_video_capture,
|
|
647
|
-
self.video_device,
|
|
648
|
-
self.width,
|
|
649
|
-
self.height,
|
|
650
|
-
self.fps,
|
|
651
|
-
|
|
651
|
+
create_video_capture,
|
|
652
|
+
self.video_device,
|
|
653
|
+
self.width,
|
|
654
|
+
self.height,
|
|
655
|
+
self.fps,
|
|
656
|
+
self.robovision_url,
|
|
657
|
+
)
|
|
652
658
|
self._motion_reference = None
|
|
653
659
|
if self._motion_capture is None:
|
|
654
660
|
continue
|
|
655
|
-
logger.info("motion
|
|
661
|
+
logger.info("motion sampler connected to RoboVision shared stream")
|
|
656
662
|
frame = await asyncio.to_thread(self._motion_capture.read)
|
|
657
663
|
if frame is not None:
|
|
658
664
|
self._detect_motion(frame)
|
|
@@ -921,6 +927,20 @@ class PreviewAgent:
|
|
|
921
927
|
self._next_mesh_bootstrap = 0.0
|
|
922
928
|
if production_mode is not None:
|
|
923
929
|
self.production_mode = production_mode
|
|
930
|
+
# Exactly one motion detector is active. With the default
|
|
931
|
+
# local sampler enabled, preview reads RoboVision's shared
|
|
932
|
+
# MJPEG stream and RoboVision only owns capture/encoding.
|
|
933
|
+
robovision_motion = bool(production_mode and not self.local_camera_motion)
|
|
934
|
+
if self._robovision_motion_state != robovision_motion:
|
|
935
|
+
try:
|
|
936
|
+
response = await self._session.post(
|
|
937
|
+
f"{self.robovision_url.rstrip('/')}/api/motion/toggle",
|
|
938
|
+
json={"enabled": robovision_motion}, timeout=1.5,
|
|
939
|
+
)
|
|
940
|
+
response.raise_for_status()
|
|
941
|
+
self._robovision_motion_state = robovision_motion
|
|
942
|
+
except Exception as exc:
|
|
943
|
+
logger.warning(f"failed to synchronize RoboVision motion mode: {exc}")
|
|
924
944
|
await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
|
|
925
945
|
inventory = _get_device_inventory()
|
|
926
946
|
real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
|
|
@@ -1218,6 +1238,7 @@ class LiveKitPublisher:
|
|
|
1218
1238
|
self._capture: Optional["VideoCapture"] = None
|
|
1219
1239
|
self._mic_capture: Optional["AudioCapture"] = None
|
|
1220
1240
|
self._mic_streaming = asyncio.Event()
|
|
1241
|
+
self._mic_error: Optional[str] = None
|
|
1221
1242
|
# Motion sampling state belongs to the publisher instance. Keeping it
|
|
1222
1243
|
# initialized here prevents shutdown/reopen paths from raising while
|
|
1223
1244
|
# the camera is being handed between preview and voice sessions.
|
|
@@ -1315,10 +1336,16 @@ class LiveKitPublisher:
|
|
|
1315
1336
|
try:
|
|
1316
1337
|
await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
|
|
1317
1338
|
except asyncio.TimeoutError:
|
|
1339
|
+
detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
|
|
1318
1340
|
await self.agent._report_pipeline(
|
|
1319
|
-
"microphone_published", "blocked",
|
|
1341
|
+
"microphone_published", "blocked",
|
|
1342
|
+
f"Microphone track published but PCM capture did not start: {detail}",
|
|
1343
|
+
{"device": self.agent.audio_capture_device, "error": detail},
|
|
1344
|
+
)
|
|
1345
|
+
logger.warning(
|
|
1346
|
+
"microphone PCM is not ready (%s); keeping the session alive while capture retries",
|
|
1347
|
+
detail,
|
|
1320
1348
|
)
|
|
1321
|
-
logger.warning("microphone PCM is not ready; keeping the session alive while capture retries")
|
|
1322
1349
|
|
|
1323
1350
|
if self._capture and self.video_source:
|
|
1324
1351
|
self._tasks.append(asyncio.create_task(self._video_loop()))
|
|
@@ -1668,6 +1695,7 @@ class LiveKitPublisher:
|
|
|
1668
1695
|
except asyncio.CancelledError:
|
|
1669
1696
|
raise
|
|
1670
1697
|
except Exception as e:
|
|
1698
|
+
self._mic_error = str(e)
|
|
1671
1699
|
logger.warning(
|
|
1672
1700
|
f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
|
|
1673
1701
|
)
|
|
@@ -1689,7 +1717,8 @@ class LiveKitPublisher:
|
|
|
1689
1717
|
logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
|
|
1690
1718
|
return
|
|
1691
1719
|
self._mic_capture = mic
|
|
1692
|
-
|
|
1720
|
+
self._mic_error = None
|
|
1721
|
+
logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
|
|
1693
1722
|
# Read in bigger batches (100ms) instead of one 20ms frame per
|
|
1694
1723
|
# asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
|
|
1695
1724
|
# overhead (~30ms observed on this machine) that dominates when the
|
|
@@ -1929,46 +1958,92 @@ class AlsaAudioCapture(AudioCapture):
|
|
|
1929
1958
|
"""Capture Linux PCM through the same ALSA path used by onsite tests."""
|
|
1930
1959
|
|
|
1931
1960
|
def __init__(self, device: str):
|
|
1961
|
+
import os
|
|
1932
1962
|
import re
|
|
1963
|
+
import select
|
|
1933
1964
|
import subprocess
|
|
1934
1965
|
from media_lock import media_lock
|
|
1935
1966
|
|
|
1936
1967
|
match = re.search(r"\b(hw:\d+,\d+)\b", device)
|
|
1937
1968
|
if not match:
|
|
1938
1969
|
raise ValueError(f"no ALSA hardware address in {device!r}")
|
|
1939
|
-
alsa_device = f"plug{match.group(1)}"
|
|
1940
1970
|
self.buffer = bytearray()
|
|
1941
|
-
self.media_guard = media_lock("microphone", timeout=5
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
)
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1971
|
+
self.media_guard = media_lock("microphone", timeout=1.5).acquire()
|
|
1972
|
+
self.process = None
|
|
1973
|
+
self.source_rate = 48000
|
|
1974
|
+
self.rate_state = None
|
|
1975
|
+
errors = []
|
|
1976
|
+
# The fleet USB microphone normally accepts 48 kHz through ALSA's
|
|
1977
|
+
# plug layer. Some firmware revisions expose only native 44.1 kHz;
|
|
1978
|
+
# accept that rate and resample below rather than publishing silence.
|
|
1979
|
+
for source_rate in (48000, 44100):
|
|
1980
|
+
alsa_device = f"plug{match.group(1)}"
|
|
1981
|
+
process = None
|
|
1982
|
+
try:
|
|
1983
|
+
process = subprocess.Popen(
|
|
1984
|
+
[
|
|
1985
|
+
"arecord", "-q", "-D", alsa_device, "-t", "raw",
|
|
1986
|
+
"-f", "S16_LE", "-r", str(source_rate), "-c", "1",
|
|
1987
|
+
"--period-size", str(max(256, source_rate // 50)),
|
|
1988
|
+
],
|
|
1989
|
+
stdout=subprocess.PIPE,
|
|
1990
|
+
stderr=subprocess.PIPE,
|
|
1991
|
+
bufsize=0,
|
|
1992
|
+
)
|
|
1993
|
+
if process.stdout is None:
|
|
1994
|
+
raise RuntimeError("arecord did not provide a PCM stream")
|
|
1995
|
+
ready, _, _ = select.select([process.stdout], [], [], 2.0)
|
|
1996
|
+
if not ready:
|
|
1997
|
+
if process.poll() is None:
|
|
1998
|
+
raise TimeoutError("arecord produced no PCM within 2 seconds")
|
|
1999
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2000
|
+
raise OSError(detail or f"arecord exited {process.returncode}")
|
|
2001
|
+
first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
|
|
2002
|
+
if not first:
|
|
2003
|
+
detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
|
|
2004
|
+
raise OSError(detail or "arecord returned an empty PCM frame")
|
|
2005
|
+
self.process = process
|
|
2006
|
+
self.source_rate = source_rate
|
|
2007
|
+
self.alsa_device = alsa_device
|
|
2008
|
+
if source_rate != 48000:
|
|
2009
|
+
import audioop
|
|
2010
|
+
first, self.rate_state = audioop.ratecv(
|
|
2011
|
+
first, 2, 1, source_rate, 48000, self.rate_state
|
|
2012
|
+
)
|
|
2013
|
+
self.buffer.extend(first)
|
|
2014
|
+
break
|
|
2015
|
+
except Exception as exc:
|
|
2016
|
+
errors.append(f"{source_rate}Hz: {exc}")
|
|
2017
|
+
if process is not None:
|
|
2018
|
+
if process.poll() is None:
|
|
2019
|
+
process.terminate()
|
|
2020
|
+
try:
|
|
2021
|
+
process.wait(timeout=1)
|
|
2022
|
+
except subprocess.TimeoutExpired:
|
|
2023
|
+
process.kill()
|
|
2024
|
+
process.wait(timeout=1)
|
|
2025
|
+
if self.process is None:
|
|
1957
2026
|
self.media_guard.release()
|
|
1958
|
-
raise
|
|
1959
|
-
self.alsa_device = alsa_device
|
|
2027
|
+
raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
|
|
1960
2028
|
|
|
1961
2029
|
def read(self, samples_per_frame: int):
|
|
2030
|
+
import audioop
|
|
2031
|
+
import os
|
|
1962
2032
|
from livekit import rtc
|
|
1963
2033
|
|
|
1964
2034
|
bytes_needed = samples_per_frame * 2
|
|
2035
|
+
source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
|
|
1965
2036
|
while len(self.buffer) < bytes_needed:
|
|
1966
|
-
chunk = self.process.stdout.
|
|
2037
|
+
chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
|
|
1967
2038
|
if not chunk:
|
|
1968
2039
|
detail = ""
|
|
1969
2040
|
if self.process.stderr is not None:
|
|
1970
2041
|
detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
|
|
1971
2042
|
raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
|
|
2043
|
+
if self.source_rate != 48000:
|
|
2044
|
+
chunk, self.rate_state = audioop.ratecv(
|
|
2045
|
+
chunk, 2, 1, self.source_rate, 48000, self.rate_state
|
|
2046
|
+
)
|
|
1972
2047
|
self.buffer.extend(chunk)
|
|
1973
2048
|
data = bytes(self.buffer[:bytes_needed])
|
|
1974
2049
|
del self.buffer[:bytes_needed]
|
|
@@ -2128,7 +2203,7 @@ def main() -> None:
|
|
|
2128
2203
|
"audio_device": args.audio_device,
|
|
2129
2204
|
"robovision_url": args.robovision_url or os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"),
|
|
2130
2205
|
"use_robovision_camera": bool(args.robovision_url) or os.getenv(
|
|
2131
|
-
"ROBOVISION_CAMERA", ""
|
|
2206
|
+
"ROBOVISION_CAMERA", "true"
|
|
2132
2207
|
).lower() in ("1", "true", "yes", "on"),
|
|
2133
2208
|
"video_width": args.width,
|
|
2134
2209
|
"video_height": args.height,
|
|
@@ -17,8 +17,19 @@ CORS(app)
|
|
|
17
17
|
|
|
18
18
|
# Camera management. Production installs create /dev/robopark-camera from
|
|
19
19
|
# the primary USB capture interface, avoiding /dev/videoN renumbering.
|
|
20
|
+
def _normalize_camera_device(value):
|
|
21
|
+
configured = str(value or "").strip()
|
|
22
|
+
if configured.lower() in ("", "auto", "default", "first"):
|
|
23
|
+
if sys.platform.startswith("linux"):
|
|
24
|
+
return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
|
|
25
|
+
return 0
|
|
26
|
+
if configured.isdigit():
|
|
27
|
+
return int(configured)
|
|
28
|
+
return configured
|
|
29
|
+
|
|
30
|
+
|
|
20
31
|
_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
|
|
21
|
-
current_camera_index = _configured_camera
|
|
32
|
+
current_camera_index = _normalize_camera_device(_configured_camera)
|
|
22
33
|
camera = None
|
|
23
34
|
camera_lock = threading.Lock()
|
|
24
35
|
frame_condition = threading.Condition()
|
|
@@ -41,9 +52,17 @@ def _open_camera(index):
|
|
|
41
52
|
# webcam — isOpened() then stays False forever and callers retry in an
|
|
42
53
|
# infinite doomed loop. Force DirectShow, the backend that actually
|
|
43
54
|
# enumerates standard Windows webcams.
|
|
44
|
-
if sys.platform == 'win32':
|
|
45
|
-
return cv2.VideoCapture(index, cv2.CAP_DSHOW)
|
|
46
|
-
|
|
55
|
+
if sys.platform == 'win32':
|
|
56
|
+
return cv2.VideoCapture(index, cv2.CAP_DSHOW)
|
|
57
|
+
if sys.platform.startswith('linux'):
|
|
58
|
+
cap = cv2.VideoCapture(index, cv2.CAP_V4L2)
|
|
59
|
+
if cap.isOpened():
|
|
60
|
+
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
|
|
61
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
|
|
62
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
|
|
63
|
+
cap.set(cv2.CAP_PROP_FPS, 15)
|
|
64
|
+
return cap
|
|
65
|
+
return cv2.VideoCapture(index)
|
|
47
66
|
|
|
48
67
|
|
|
49
68
|
def get_camera():
|
|
@@ -469,7 +488,7 @@ def switch_camera():
|
|
|
469
488
|
with camera_lock:
|
|
470
489
|
if camera:
|
|
471
490
|
camera.release()
|
|
472
|
-
current_camera_index = new_index
|
|
491
|
+
current_camera_index = _normalize_camera_device(new_index)
|
|
473
492
|
camera = None
|
|
474
493
|
|
|
475
494
|
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
@@ -531,7 +550,7 @@ def media_config():
|
|
|
531
550
|
switch_camera_value = value
|
|
532
551
|
if camera:
|
|
533
552
|
camera.release()
|
|
534
|
-
current_camera_index =
|
|
553
|
+
current_camera_index = _normalize_camera_device(switch_camera_value)
|
|
535
554
|
camera = None
|
|
536
555
|
if "audio_device" in data:
|
|
537
556
|
audio_input_device = str(data["audio_device"])
|