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: opts.videoDevice ?? '/dev/robopark-camera',
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', opts.videoDevice ?? '/dev/video0',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.94",
3
+ "version": "2.8.96",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -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", False))
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
- """Own the camera while idle and detect motion without a second process."""
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 camera opened by preview agent")
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", "Microphone track published but PCM capture did not start"
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
- logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
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.0).acquire()
1942
- try:
1943
- self.process = subprocess.Popen(
1944
- [
1945
- "arecord", "-q", "-D", alsa_device, "-t", "raw",
1946
- "-f", "S16_LE", "-r", "48000", "-c", "1",
1947
- ],
1948
- stdout=subprocess.PIPE,
1949
- stderr=subprocess.PIPE,
1950
- )
1951
- except Exception:
1952
- self.media_guard.release()
1953
- raise
1954
- if self.process.stdout is None:
1955
- self.process.kill()
1956
- self.process.wait(timeout=1)
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 RuntimeError("arecord did not provide a PCM stream")
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.read(bytes_needed - len(self.buffer))
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 or ("/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else 0)
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
- return cv2.VideoCapture(index)
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 = int(switch_camera_value) if switch_camera_value.isdigit() else switch_camera_value
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"])