infinicode 2.8.99 → 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",
@@ -1284,6 +1288,35 @@ class LiveKitPublisher:
1284
1288
  self._mic_capture: Optional["AudioCapture"] = None
1285
1289
  self._mic_streaming = asyncio.Event()
1286
1290
  self._mic_error: Optional[str] = None
1291
+ # The launch hardware has no acoustic echo cancellation. Publishing
1292
+ # the amplified USB microphone while the robot speaker plays TTS makes
1293
+ # the voice worker hear itself and trigger barge-in, truncating or
1294
+ # chopping its own response. Keep the track alive with silence while
1295
+ # playback is active, plus a short room-echo decay tail.
1296
+ self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
1297
+ "1", "true", "yes", "on",
1298
+ )
1299
+ self._speaker_playback_active = threading.Event()
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
1305
+ self._speaker_echo_tail = max(
1306
+ 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "700")) / 1000.0, 2.0)
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
+ )
1287
1320
  # Motion sampling state belongs to the publisher instance. Keeping it
1288
1321
  # initialized here prevents shutdown/reopen paths from raising while
1289
1322
  # the camera is being handed between preview and voice sessions.
@@ -1524,12 +1557,45 @@ class LiveKitPublisher:
1524
1557
  stream_failed = threading.Event()
1525
1558
  playback_reported = threading.Event()
1526
1559
  event_loop = asyncio.get_running_loop()
1560
+ echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
1561
+
1562
+ def _outbound_peak(chunk: bytes) -> int:
1563
+ if len(chunk) < 2:
1564
+ return 0
1565
+ try:
1566
+ samples = memoryview(chunk).cast("h")
1567
+ # Stereo duplication means sampling every eighth value is
1568
+ # sufficient and keeps the writer thread lightweight.
1569
+ return max((abs(int(value)) for value in samples[::8]), default=0)
1570
+ except (TypeError, ValueError):
1571
+ return 0
1572
+
1573
+ def _open_echo_gate() -> None:
1574
+ if not self._half_duplex:
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
1580
+ self._speaker_playback_active.set()
1581
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1582
+
1583
+ def _extend_echo_gate() -> None:
1584
+ if self._half_duplex:
1585
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1527
1586
 
1528
- def _safe_write(chunk: bytes) -> bool:
1529
- if stream_failed.is_set():
1530
- return False
1587
+ def _safe_write(chunk: bytes) -> bool:
1588
+ if stream_failed.is_set():
1589
+ return False
1531
1590
  try:
1591
+ audible = _outbound_peak(chunk) >= echo_gate_peak
1592
+ if audible:
1593
+ _open_echo_gate()
1594
+ elif time.monotonic() >= self._speaker_gate_until:
1595
+ self._speaker_playback_active.clear()
1532
1596
  out.write(chunk)
1597
+ if audible:
1598
+ _extend_echo_gate()
1533
1599
  if not playback_reported.is_set():
1534
1600
  playback_reported.set()
1535
1601
  asyncio.run_coroutine_threadsafe(
@@ -1661,6 +1727,9 @@ class LiveKitPublisher:
1661
1727
  pa.terminate()
1662
1728
  if speaker_guard is not None:
1663
1729
  speaker_guard.release()
1730
+ if self._half_duplex:
1731
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1732
+ self._speaker_playback_active.clear()
1664
1733
 
1665
1734
  async def stop(self) -> None:
1666
1735
  self._stop_event.set()
@@ -1783,8 +1852,9 @@ class LiveKitPublisher:
1783
1852
  _diag_count = 0
1784
1853
  _diag_last_log = time.monotonic()
1785
1854
  _diag_read_ms = 0.0
1786
- _diag_publish_ms = 0.0
1787
- batch_interval = BATCH_MS / 1000
1855
+ _diag_publish_ms = 0.0
1856
+ _echo_gate_was_active = False
1857
+ batch_interval = BATCH_MS / 1000
1788
1858
  while not self._stop_event.is_set():
1789
1859
  _iter_start = time.monotonic()
1790
1860
  _t0 = time.monotonic()
@@ -1792,10 +1862,56 @@ class LiveKitPublisher:
1792
1862
  _t1 = time.monotonic()
1793
1863
  if batch is not None:
1794
1864
  data = bytes(batch.data)
1795
- _pub_start = time.monotonic()
1796
- for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1865
+ _pub_start = time.monotonic()
1866
+ for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1797
1867
  chunk = data[off:off + bytes_per_frame]
1798
- chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1868
+ now = time.monotonic()
1869
+ echo_gate_active = self._half_duplex and (
1870
+ self._speaker_playback_active.is_set()
1871
+ or now < self._speaker_gate_until
1872
+ )
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:
1902
+ # Preserve 20 ms frame cadence; only suppress content.
1903
+ # Stopping publication would create gaps and destabilize
1904
+ # VAD/endpointing when listening resumes.
1905
+ chunk = b"\x00" * len(chunk)
1906
+ p = 0
1907
+ else:
1908
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1909
+ if echo_gate_active != _echo_gate_was_active:
1910
+ logger.info(
1911
+ "audio_loop: speaker echo gate %s",
1912
+ "active" if echo_gate_active else "released",
1913
+ )
1914
+ _echo_gate_was_active = echo_gate_active
1799
1915
  frame = self.rtc.AudioFrame(
1800
1916
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1801
1917
  )