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.
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +322 -57
- package/dist/robopark/agent-ctl.js +42 -26
- package/dist/robopark/robot-runtime.js +15 -0
- package/docs/ROBOPARK_PRODUCTION_MEMORY.md +39 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +47 -0
- package/packages/robopark/scheduler/preview_agent.py +139 -23
|
@@ -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
|
-
|
|
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
|
-
#
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|