infinicode 2.8.100 → 2.8.102

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.
@@ -5019,6 +5019,53 @@ async def delete_character_preset(preset_id: str):
5019
5019
  return {"ok": True}
5020
5020
 
5021
5021
 
5022
+ class PresetPosition(BaseModel):
5023
+ id: str
5024
+ nx: float
5025
+ ny: float
5026
+
5027
+
5028
+ class PresetPositionBatch(BaseModel):
5029
+ positions: List[PresetPosition]
5030
+
5031
+
5032
+ @app.put("/api/character-presets/positions")
5033
+ async def update_preset_positions(payload: PresetPositionBatch):
5034
+ """Batch-update (id, nx, ny) for the Park map. nx/ny are the
5035
+ normalized [0,1] pad coordinates the dashboard uses to place each
5036
+ robot on the iso park. Bounded + persisted so an operator can
5037
+ Ctrl+drag a robot on the Park map and have its position survive
5038
+ a refresh, server restart, or robot reconnect.
5039
+
5040
+ Designed for small, infrequent updates (a single drag releases
5041
+ one robot, plus an occasional "reset all" tool). The endpoint
5042
+ intentionally ignores all other preset fields — only the
5043
+ coordinates move; voice stack, name, system prompt, etc. are
5044
+ owned by the character-preset editor and are not touched here.
5045
+ """
5046
+ if not payload.positions:
5047
+ return {"updated": 0}
5048
+ now = datetime.utcnow().isoformat()
5049
+ updated = 0
5050
+ async with aiosqlite.connect(DB_PATH) as db:
5051
+ for p in payload.positions:
5052
+ if not p.id:
5053
+ continue
5054
+ # Clamp nx/ny to the visible pad rectangle so a stray
5055
+ # drag can't park a robot off the island. A small inner
5056
+ # margin (0.04) keeps the avatar fully on the grass and
5057
+ # clear of the gold wall on the perimeter.
5058
+ nx = max(0.04, min(0.96, float(p.nx)))
5059
+ ny = max(0.04, min(0.96, float(p.ny)))
5060
+ cur = await db.execute(
5061
+ "UPDATE character_presets SET nx = ?, ny = ?, updated_at = ? WHERE id = ?",
5062
+ (nx, ny, now, p.id),
5063
+ )
5064
+ updated += cur.rowcount or 0
5065
+ await db.commit()
5066
+ return {"updated": updated}
5067
+
5068
+
5022
5069
  class RobotVoiceConfigUpdate(BaseModel):
5023
5070
  character_preset_id: Optional[str] = None
5024
5071
  voice_stack_id: Optional[str] = None
@@ -541,12 +541,17 @@ class PreviewAgent:
541
541
  self.vision_trigger_cooldown = float(cfg.get("vision_trigger_cooldown", os.getenv("VISION_TRIGGER_COOLDOWN", DEFAULT_VISION_TRIGGER_COOLDOWN)))
542
542
  self.vision_session_seconds = float(cfg.get("vision_session_seconds", os.getenv("VISION_SESSION_SECONDS", DEFAULT_VISION_SESSION_SECONDS)))
543
543
  self.vision_silence_timeout = float(cfg.get("vision_silence_timeout", os.getenv("VISION_SILENCE_TIMEOUT", DEFAULT_VISION_SILENCE_TIMEOUT)))
544
- self.local_camera_motion = str(
544
+ self.local_camera_motion = str(
545
545
  # Production needs an always-on detector. The preview publisher
546
546
  # remains the single camera owner when a session starts, so this
547
547
  # does not require the separate OpenCV vision server.
548
548
  cfg.get("local_camera_motion", os.getenv("LOCAL_CAMERA_MOTION", "true"))
549
- ).lower() in ("1", "true", "yes", "on")
549
+ ).lower() in ("1", "true", "yes", "on")
550
+ # The character greeting is the production acknowledgement. A local
551
+ # motion beep serializes on the same ALSA output and delays allocation.
552
+ self.motion_cue_enabled = str(
553
+ cfg.get("motion_cue_enabled", os.getenv("ROBOPARK_MOTION_CUE", "false"))
554
+ ).lower() in ("1", "true", "yes", "on")
550
555
 
551
556
  self._shutdown = asyncio.Event()
552
557
  self._task: Optional[asyncio.Task] = None
@@ -1178,20 +1183,19 @@ class PreviewAgent:
1178
1183
  logger.warning("vision motion event received but not enrolled yet — ignoring")
1179
1184
  return
1180
1185
  self._vision_trigger_in_flight = True
1181
- await self._report_pipeline("motion_detected", "ok", f"Motion received from {payload.get('source', 'camera')}", once=False)
1186
+ # Observability must not add a scheduler round trip before the actual
1187
+ # session request. The reporter handles and logs its own errors.
1188
+ asyncio.create_task(self._report_pipeline(
1189
+ "motion_detected", "ok",
1190
+ f"Motion received from {payload.get('source', 'camera')}", once=False,
1191
+ ))
1182
1192
  logger.info("motion detected by RoboVisionAI_PI — requesting a session")
1183
- # The cue is feedback only; never block session allocation on speaker
1184
- await self._stop_motion_capture()
1185
- # DirectShow can return a stale handle for a short interval after
1186
- # release. Give the driver time to finish teardown before reopening it
1187
- # for the LiveKit publisher.
1188
- await asyncio.sleep(0.5)
1189
- # I/O. Camera/mic join and the preset greeting are the critical path.
1190
- # The cue and TTS share one physical ALSA output. Finish and release
1191
- # the cue before LiveKit can deliver the greeting; background playback
1192
- # races aplay and makes greeting delivery intermittently fail busy.
1193
- async with self._speaker_operation_lock:
1194
- await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1193
+ # RoboVision owns the physical camera and exposes a shared stream, so
1194
+ # there is no capture handle to tear down and no reason to sleep here.
1195
+ if self.motion_cue_enabled:
1196
+ logger.warning("ROBOPARK_MOTION_CUE is enabled; the diagnostic cue adds greeting latency")
1197
+ async with self._speaker_operation_lock:
1198
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1195
1199
  try:
1196
1200
  r = await self._session.post(
1197
1201
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
@@ -1294,9 +1298,25 @@ class LiveKitPublisher:
1294
1298
  )
1295
1299
  self._speaker_playback_active = threading.Event()
1296
1300
  self._speaker_gate_until = 0.0
1301
+ self._speaker_started_at = 0.0
1302
+ self._barge_in_until = 0.0
1303
+ self._echo_mic_floor = 0.0
1304
+ self._barge_in_candidate_frames = 0
1297
1305
  self._speaker_echo_tail = max(
1298
1306
  0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "700")) / 1000.0, 2.0)
1299
1307
  )
1308
+ self._adaptive_barge_in = str(
1309
+ os.getenv("ROBOPARK_ADAPTIVE_BARGE_IN", "true")
1310
+ ).lower() in ("1", "true", "yes", "on")
1311
+ self._barge_in_min_peak = max(
1312
+ 256, min(int(os.getenv("ROBOPARK_BARGE_IN_MIN_PEAK", "2200")), 20000)
1313
+ )
1314
+ self._barge_in_ratio = max(
1315
+ 1.25, min(float(os.getenv("ROBOPARK_BARGE_IN_ECHO_RATIO", "2.4")), 8.0)
1316
+ )
1317
+ self._barge_in_hold = max(
1318
+ 0.4, min(float(os.getenv("ROBOPARK_BARGE_IN_HOLD_MS", "1400")) / 1000.0, 3.0)
1319
+ )
1300
1320
  # Motion sampling state belongs to the publisher instance. Keeping it
1301
1321
  # initialized here prevents shutdown/reopen paths from raising while
1302
1322
  # the camera is being handed between preview and voice sessions.
@@ -1553,6 +1573,10 @@ class LiveKitPublisher:
1553
1573
  def _open_echo_gate() -> None:
1554
1574
  if not self._half_duplex:
1555
1575
  return
1576
+ if not self._speaker_playback_active.is_set():
1577
+ self._speaker_started_at = time.monotonic()
1578
+ self._echo_mic_floor = 0.0
1579
+ self._barge_in_candidate_frames = 0
1556
1580
  self._speaker_playback_active.set()
1557
1581
  self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1558
1582
 
@@ -1841,11 +1865,40 @@ class LiveKitPublisher:
1841
1865
  _pub_start = time.monotonic()
1842
1866
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1843
1867
  chunk = data[off:off + bytes_per_frame]
1868
+ now = time.monotonic()
1844
1869
  echo_gate_active = self._half_duplex and (
1845
1870
  self._speaker_playback_active.is_set()
1846
- or time.monotonic() < self._speaker_gate_until
1871
+ or now < self._speaker_gate_until
1847
1872
  )
1848
- if echo_gate_active:
1873
+ # Learn the microphone's speaker-echo floor while output is
1874
+ # active. A nearby visitor speaking produces a fast peak well
1875
+ # above that floor; reopen the mic briefly so LiveKit VAD can
1876
+ # cancel normal TTS. The initial 300 ms remains protected.
1877
+ _, raw_peak = _pcm16_scale_and_peak(chunk, 1.0)
1878
+ if echo_gate_active and self._adaptive_barge_in:
1879
+ if self._echo_mic_floor <= 0:
1880
+ self._echo_mic_floor = float(raw_peak)
1881
+ else:
1882
+ self._echo_mic_floor = self._echo_mic_floor * 0.92 + raw_peak * 0.08
1883
+ threshold = max(
1884
+ self._barge_in_min_peak,
1885
+ int(self._echo_mic_floor * self._barge_in_ratio),
1886
+ )
1887
+ warmed = now - self._speaker_started_at >= 0.3
1888
+ if warmed and raw_peak >= threshold:
1889
+ self._barge_in_candidate_frames += 1
1890
+ else:
1891
+ self._barge_in_candidate_frames = 0
1892
+ if self._barge_in_candidate_frames >= 3:
1893
+ if now >= self._barge_in_until:
1894
+ logger.info(
1895
+ "audio_loop: adaptive barge-in opened mic "
1896
+ "(peak=%d threshold=%d echo_floor=%d)",
1897
+ raw_peak, threshold, int(self._echo_mic_floor),
1898
+ )
1899
+ self._barge_in_until = now + self._barge_in_hold
1900
+ barge_in_active = self._adaptive_barge_in and now < self._barge_in_until
1901
+ if echo_gate_active and not barge_in_active:
1849
1902
  # Preserve 20 ms frame cadence; only suppress content.
1850
1903
  # Stopping publication would create gaps and destabilize
1851
1904
  # VAD/endpointing when listening resumes.