infinicode 2.8.87 → 2.8.89

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.
@@ -60,6 +60,10 @@ ROBOVOICE checkout, and rebuilds Compose once.
60
60
  The preview agent previously used PyAudio for the selected speaker. BMW's
61
61
  proven output is ALSA `aplay -D plughw:2,0`. Infinicode `2.8.86` uses that
62
62
  same endpoint for live TTS whenever a Linux output selection contains `hw:`.
63
+ Motion cues must use that same ALSA endpoint and finish before the greeting
64
+ starts. Running a PyAudio cue concurrently with ALSA TTS creates an exclusive
65
+ device race on `hw:2,0`. `playback_started` is valid only after PCM has been
66
+ written to the physical speaker, not merely received from LiveKit.
63
67
 
64
68
  ### Silent Conversation After Greeting
65
69
 
@@ -70,6 +74,14 @@ for actual PCM frames before reporting `microphone_published`, reports a blocked
70
74
  stage when capture does not start within five seconds, and applies bounded mic
71
75
  gain before publication.
72
76
 
77
+ ### Camera Relay With Multiple Viewers
78
+
79
+ RoboVision must have exactly one OpenCV camera reader. Preview, dashboard, and
80
+ motion detection consume a shared latest-frame stream. Starting one
81
+ `generate_frames()` camera loop per MJPEG client causes concurrent
82
+ `VideoCapture.read()` calls, which can block the second client and make the hub
83
+ relay time out with zero bytes.
84
+
73
85
  ### Duplicate Speaker Tests
74
86
 
75
87
  Only one robot process may claim a scheduler `speaker_test`. The scheduler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.87",
3
+ "version": "2.8.89",
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",
@@ -83,22 +83,51 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
83
83
  return url
84
84
 
85
85
 
86
- def _play_audio_effect(selected_output: str | None, effect: str) -> None:
87
- """Play a short local cue without involving the voice pipeline."""
88
- try:
89
- import pyaudio
90
- except Exception:
91
- return
92
- sample_rate = 48000
93
- channels = 2
86
+ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
87
+ """Play a short local cue without involving the voice pipeline."""
88
+ sample_rate = 48000
89
+ channels = 2
94
90
  if effect == "motion":
95
91
  notes = ((880, 0.09), (1320, 0.13))
96
92
  else:
97
93
  notes = ((660, 0.10), (440, 0.16))
98
- pa = pyaudio.PyAudio()
99
- device_index = None
100
- selected = str(selected_output or "default")
101
- try:
94
+ selected = str(selected_output or "default")
95
+ frames = bytearray()
96
+ for frequency, duration in notes:
97
+ count = int(sample_rate * duration)
98
+ for n in range(count):
99
+ envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
100
+ value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
101
+ frames.extend(struct.pack("<hh", value, value))
102
+
103
+ if sys.platform.startswith("linux") and "hw:" in selected:
104
+ import re
105
+ import subprocess
106
+
107
+ match = re.search(r"\b(hw:\d+,\d+)\b", selected)
108
+ if not match:
109
+ return
110
+ result = subprocess.run(
111
+ [
112
+ "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
113
+ "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
114
+ ],
115
+ input=bytes(frames), capture_output=True, timeout=3.0,
116
+ )
117
+ if result.returncode:
118
+ logger.warning(
119
+ "audio effect failed on %s: %s",
120
+ match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
121
+ )
122
+ return
123
+
124
+ try:
125
+ import pyaudio
126
+ except Exception:
127
+ return
128
+ pa = pyaudio.PyAudio()
129
+ device_index = None
130
+ try:
102
131
  if selected.strip().isdigit():
103
132
  device_index = int(selected.strip())
104
133
  elif selected.lower() == "default":
@@ -118,14 +147,7 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
118
147
  output=True,
119
148
  output_device_index=device_index,
120
149
  )
121
- for frequency, duration in notes:
122
- count = int(sample_rate * duration)
123
- frames = bytearray()
124
- for n in range(count):
125
- envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
126
- value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
127
- frames.extend(struct.pack("<hh", value, value))
128
- stream.write(bytes(frames))
150
+ stream.write(bytes(frames))
129
151
  stream.stop_stream()
130
152
  stream.close()
131
153
  except Exception as e:
@@ -1041,9 +1063,10 @@ class PreviewAgent:
1041
1063
  # for the LiveKit publisher.
1042
1064
  await asyncio.sleep(0.5)
1043
1065
  # I/O. Camera/mic join and the preset greeting are the critical path.
1044
- asyncio.create_task(
1045
- asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1046
- )
1066
+ # The cue and TTS share one physical ALSA output. Finish and release
1067
+ # the cue before LiveKit can deliver the greeting; background playback
1068
+ # races aplay and makes greeting delivery intermittently fail busy.
1069
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1047
1070
  try:
1048
1071
  r = await self._session.post(
1049
1072
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -1350,17 +1373,27 @@ class LiveKitPublisher:
1350
1373
  # steady pace, independent of how unevenly frames actually arrive.
1351
1374
  import queue
1352
1375
  import threading
1353
- write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1354
- written_frames = [0]
1355
- PREBUFFER_CHUNKS = 5
1356
- stream_failed = threading.Event()
1376
+ write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
1377
+ written_frames = [0]
1378
+ PREBUFFER_CHUNKS = 5
1379
+ stream_failed = threading.Event()
1380
+ playback_reported = threading.Event()
1381
+ event_loop = asyncio.get_running_loop()
1357
1382
 
1358
1383
  def _safe_write(chunk: bytes) -> bool:
1359
1384
  if stream_failed.is_set():
1360
1385
  return False
1361
- try:
1362
- out.write(chunk)
1363
- return True
1386
+ try:
1387
+ out.write(chunk)
1388
+ if not playback_reported.is_set():
1389
+ playback_reported.set()
1390
+ asyncio.run_coroutine_threadsafe(
1391
+ self.agent._report_pipeline(
1392
+ "playback_started", "ok", "First TTS audio chunk written to robot speaker"
1393
+ ),
1394
+ event_loop,
1395
+ )
1396
+ return True
1364
1397
  except Exception as e:
1365
1398
  stream_failed.set()
1366
1399
  logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
@@ -1420,7 +1453,6 @@ class LiveKitPublisher:
1420
1453
  f"audio out: first frame received for {sid} "
1421
1454
  f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
1422
1455
  )
1423
- await self.agent._report_pipeline("playback_started", "ok", "First TTS audio frame reached the robot")
1424
1456
  in_rate = af.sample_rate
1425
1457
  in_channels = int(getattr(af, "num_channels", 1) or 1)
1426
1458
  raw = bytes(af.data)
@@ -18,6 +18,11 @@ CORS(app)
18
18
  # Camera management
19
19
  current_camera_index = 0
20
20
  camera = None
21
+ camera_lock = threading.Lock()
22
+ frame_condition = threading.Condition()
23
+ latest_frame_bytes = None
24
+ latest_frame_sequence = 0
25
+ camera_worker_started = False
21
26
  audio_input_device = "default"
22
27
  audio_output_device = "default"
23
28
  ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
@@ -224,22 +229,25 @@ def send_webhook(frame_data):
224
229
  except Exception as e:
225
230
  print(f"Error preparing webhook: {e}")
226
231
 
227
- def generate_frames():
228
- global latest_detections, motion_detection_active, motion_detected_state
229
- global last_motion_time, motion_frame_buffer
230
-
231
- prev_gray = None
232
-
233
- while True:
234
- cam = get_camera()
235
- if cam is None or not cam.isOpened():
236
- time.sleep(1)
237
- continue
238
-
239
- success, frame = cam.read()
240
- if not success:
241
- time.sleep(0.1)
242
- continue
232
+ def camera_worker():
233
+ global latest_detections, motion_detection_active, motion_detected_state
234
+ global last_motion_time, motion_frame_buffer
235
+ global latest_frame_bytes, latest_frame_sequence
236
+
237
+ prev_gray = None
238
+
239
+ while True:
240
+ with camera_lock:
241
+ cam = get_camera()
242
+ if cam is None or not cam.isOpened():
243
+ time.sleep(1)
244
+ continue
245
+
246
+ with camera_lock:
247
+ success, frame = cam.read()
248
+ if not success:
249
+ time.sleep(0.1)
250
+ continue
243
251
 
244
252
  # Run object detection
245
253
  processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
@@ -288,11 +296,40 @@ def generate_frames():
288
296
 
289
297
  prev_gray = gray
290
298
 
291
- ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
292
- frame_bytes = buffer.tobytes()
293
-
294
- yield (b'--frame\r\n'
295
- b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
299
+ ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
300
+ if not ret:
301
+ continue
302
+ with frame_condition:
303
+ latest_frame_bytes = buffer.tobytes()
304
+ latest_frame_sequence += 1
305
+ frame_condition.notify_all()
306
+
307
+
308
+ def _ensure_camera_worker():
309
+ global camera_worker_started
310
+ with frame_condition:
311
+ if camera_worker_started:
312
+ return
313
+ camera_worker_started = True
314
+ threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
315
+
316
+
317
+ def generate_frames():
318
+ _ensure_camera_worker()
319
+ sequence = -1
320
+ while True:
321
+ with frame_condition:
322
+ frame_condition.wait_for(
323
+ lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
324
+ timeout=5.0,
325
+ )
326
+ if latest_frame_bytes is None or latest_frame_sequence == sequence:
327
+ continue
328
+ frame_bytes = latest_frame_bytes
329
+ sequence = latest_frame_sequence
330
+
331
+ yield (b'--frame\r\n'
332
+ b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
296
333
 
297
334
  @app.route('/')
298
335
  def index():
@@ -379,11 +416,11 @@ def switch_camera():
379
416
  if isinstance(new_index, str) and new_index.isdigit():
380
417
  new_index = int(new_index)
381
418
 
382
- if camera:
383
- camera.release()
384
-
385
- current_camera_index = new_index
386
- camera = None
419
+ with camera_lock:
420
+ if camera:
421
+ camera.release()
422
+ current_camera_index = new_index
423
+ camera = None
387
424
 
388
425
  return jsonify({"status": "ok", "camera_index": current_camera_index})
389
426