infinicode 2.8.99 → 2.8.100

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.
@@ -139,6 +139,12 @@ export async function roboparkRobotRuntime(opts) {
139
139
  // from these USB product labels on every inventory refresh.
140
140
  ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
141
141
  ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
142
+ // Launch hardware has no acoustic echo cancellation. Suppress mic PCM
143
+ // only while audible robot speech is playing, then hold briefly for
144
+ // room echo decay so VAD cannot interrupt the robot with its own voice.
145
+ ROBOPARK_HALF_DUPLEX: 'true',
146
+ ROBOPARK_ECHO_TAIL_MS: '700',
147
+ ROBOPARK_ECHO_GATE_PEAK: '96',
142
148
  // Inventory changes should reach the Control Center promptly.
143
149
  HEARTBEAT_INTERVAL: '5',
144
150
  },
@@ -199,3 +199,16 @@ Required result: `playback_started ok`.
199
199
  rejects duplicate assignments, limits pulses to 10 seconds, serializes all
200
200
  actuation, and de-energizes every registered relay on errors and shutdown.
201
201
  - A missing or busy GPIO driver is a failed test, never a simulated success.
202
+
203
+ ## Robot Speaker Echo Contract (2.8.100+)
204
+
205
+ - The launch USB speaker/microphone stack has no hardware acoustic echo
206
+ cancellation, so production uses robot-side half-duplex echo suppression.
207
+ - While outbound TTS contains audible PCM, the Pi publishes timing-correct
208
+ silence instead of amplified microphone PCM. The microphone track remains
209
+ connected and resumes after a 700 ms acoustic decay tail.
210
+ - Silent frames on the persistent TTS track do not hold the gate open. Output
211
+ peak detection releases the visitor microphone between responses.
212
+ - `ROBOPARK_HALF_DUPLEX`, `ROBOPARK_ECHO_TAIL_MS`, and
213
+ `ROBOPARK_ECHO_GATE_PEAK` are explicit runtime controls. Disable half-duplex
214
+ only after deploying real acoustic echo cancellation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.99",
3
+ "version": "2.8.100",
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",
@@ -1284,6 +1284,19 @@ class LiveKitPublisher:
1284
1284
  self._mic_capture: Optional["AudioCapture"] = None
1285
1285
  self._mic_streaming = asyncio.Event()
1286
1286
  self._mic_error: Optional[str] = None
1287
+ # The launch hardware has no acoustic echo cancellation. Publishing
1288
+ # the amplified USB microphone while the robot speaker plays TTS makes
1289
+ # the voice worker hear itself and trigger barge-in, truncating or
1290
+ # chopping its own response. Keep the track alive with silence while
1291
+ # playback is active, plus a short room-echo decay tail.
1292
+ self._half_duplex = str(os.getenv("ROBOPARK_HALF_DUPLEX", "true")).lower() in (
1293
+ "1", "true", "yes", "on",
1294
+ )
1295
+ self._speaker_playback_active = threading.Event()
1296
+ self._speaker_gate_until = 0.0
1297
+ self._speaker_echo_tail = max(
1298
+ 0.1, min(float(os.getenv("ROBOPARK_ECHO_TAIL_MS", "700")) / 1000.0, 2.0)
1299
+ )
1287
1300
  # Motion sampling state belongs to the publisher instance. Keeping it
1288
1301
  # initialized here prevents shutdown/reopen paths from raising while
1289
1302
  # the camera is being handed between preview and voice sessions.
@@ -1524,12 +1537,41 @@ class LiveKitPublisher:
1524
1537
  stream_failed = threading.Event()
1525
1538
  playback_reported = threading.Event()
1526
1539
  event_loop = asyncio.get_running_loop()
1540
+ echo_gate_peak = max(16, min(int(os.getenv("ROBOPARK_ECHO_GATE_PEAK", "96")), 4096))
1541
+
1542
+ def _outbound_peak(chunk: bytes) -> int:
1543
+ if len(chunk) < 2:
1544
+ return 0
1545
+ try:
1546
+ samples = memoryview(chunk).cast("h")
1547
+ # Stereo duplication means sampling every eighth value is
1548
+ # sufficient and keeps the writer thread lightweight.
1549
+ return max((abs(int(value)) for value in samples[::8]), default=0)
1550
+ except (TypeError, ValueError):
1551
+ return 0
1552
+
1553
+ def _open_echo_gate() -> None:
1554
+ if not self._half_duplex:
1555
+ return
1556
+ self._speaker_playback_active.set()
1557
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1558
+
1559
+ def _extend_echo_gate() -> None:
1560
+ if self._half_duplex:
1561
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1527
1562
 
1528
- def _safe_write(chunk: bytes) -> bool:
1529
- if stream_failed.is_set():
1530
- return False
1563
+ def _safe_write(chunk: bytes) -> bool:
1564
+ if stream_failed.is_set():
1565
+ return False
1531
1566
  try:
1567
+ audible = _outbound_peak(chunk) >= echo_gate_peak
1568
+ if audible:
1569
+ _open_echo_gate()
1570
+ elif time.monotonic() >= self._speaker_gate_until:
1571
+ self._speaker_playback_active.clear()
1532
1572
  out.write(chunk)
1573
+ if audible:
1574
+ _extend_echo_gate()
1533
1575
  if not playback_reported.is_set():
1534
1576
  playback_reported.set()
1535
1577
  asyncio.run_coroutine_threadsafe(
@@ -1661,6 +1703,9 @@ class LiveKitPublisher:
1661
1703
  pa.terminate()
1662
1704
  if speaker_guard is not None:
1663
1705
  speaker_guard.release()
1706
+ if self._half_duplex:
1707
+ self._speaker_gate_until = time.monotonic() + self._speaker_echo_tail
1708
+ self._speaker_playback_active.clear()
1664
1709
 
1665
1710
  async def stop(self) -> None:
1666
1711
  self._stop_event.set()
@@ -1783,8 +1828,9 @@ class LiveKitPublisher:
1783
1828
  _diag_count = 0
1784
1829
  _diag_last_log = time.monotonic()
1785
1830
  _diag_read_ms = 0.0
1786
- _diag_publish_ms = 0.0
1787
- batch_interval = BATCH_MS / 1000
1831
+ _diag_publish_ms = 0.0
1832
+ _echo_gate_was_active = False
1833
+ batch_interval = BATCH_MS / 1000
1788
1834
  while not self._stop_event.is_set():
1789
1835
  _iter_start = time.monotonic()
1790
1836
  _t0 = time.monotonic()
@@ -1792,10 +1838,27 @@ class LiveKitPublisher:
1792
1838
  _t1 = time.monotonic()
1793
1839
  if batch is not None:
1794
1840
  data = bytes(batch.data)
1795
- _pub_start = time.monotonic()
1796
- for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1841
+ _pub_start = time.monotonic()
1842
+ for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1797
1843
  chunk = data[off:off + bytes_per_frame]
1798
- chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1844
+ echo_gate_active = self._half_duplex and (
1845
+ self._speaker_playback_active.is_set()
1846
+ or time.monotonic() < self._speaker_gate_until
1847
+ )
1848
+ if echo_gate_active:
1849
+ # Preserve 20 ms frame cadence; only suppress content.
1850
+ # Stopping publication would create gaps and destabilize
1851
+ # VAD/endpointing when listening resumes.
1852
+ chunk = b"\x00" * len(chunk)
1853
+ p = 0
1854
+ else:
1855
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1856
+ if echo_gate_active != _echo_gate_was_active:
1857
+ logger.info(
1858
+ "audio_loop: speaker echo gate %s",
1859
+ "active" if echo_gate_active else "released",
1860
+ )
1861
+ _echo_gate_was_active = echo_gate_active
1799
1862
  frame = self.rtc.AudioFrame(
1800
1863
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1801
1864
  )