infinicode 2.8.95 → 2.8.96

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.
@@ -160,3 +160,15 @@ Required result: `playback_started ok`.
160
160
  whether motion creates a conversation, not whether the camera worker runs.
161
161
  - `auto`, `default`, and `first` resolve to `/dev/robopark-camera` when present,
162
162
  otherwise `/dev/video0`; those literal strings are never passed to OpenCV.
163
+
164
+ ## Audio Ownership Contract (2.8.96+)
165
+
166
+ - Fleet microphone selection is resolved by stable USB product label on every
167
+ inventory refresh. The current stack maps microphone to `hw:3,0` and speaker
168
+ to `hw:2,0`.
169
+ - A LiveKit microphone track is healthy only after the robot captures and
170
+ publishes real PCM. Publishing an empty track is not readiness.
171
+ - Linux capture probes `plughw` at 48 kHz, falls back to 44.1 kHz with
172
+ resampling, and releases the microphone lock after every failed attempt.
173
+ - Pipeline failures include the selected device and exact ALSA startup error.
174
+ A flat browser meter means missing PCM, not a quiet room.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.95",
3
+ "version": "2.8.96",
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",
@@ -1238,6 +1238,7 @@ class LiveKitPublisher:
1238
1238
  self._capture: Optional["VideoCapture"] = None
1239
1239
  self._mic_capture: Optional["AudioCapture"] = None
1240
1240
  self._mic_streaming = asyncio.Event()
1241
+ self._mic_error: Optional[str] = None
1241
1242
  # Motion sampling state belongs to the publisher instance. Keeping it
1242
1243
  # initialized here prevents shutdown/reopen paths from raising while
1243
1244
  # the camera is being handed between preview and voice sessions.
@@ -1335,10 +1336,16 @@ class LiveKitPublisher:
1335
1336
  try:
1336
1337
  await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1337
1338
  except asyncio.TimeoutError:
1339
+ detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
1338
1340
  await self.agent._report_pipeline(
1339
- "microphone_published", "blocked", "Microphone track published but PCM capture did not start"
1341
+ "microphone_published", "blocked",
1342
+ f"Microphone track published but PCM capture did not start: {detail}",
1343
+ {"device": self.agent.audio_capture_device, "error": detail},
1344
+ )
1345
+ logger.warning(
1346
+ "microphone PCM is not ready (%s); keeping the session alive while capture retries",
1347
+ detail,
1340
1348
  )
1341
- logger.warning("microphone PCM is not ready; keeping the session alive while capture retries")
1342
1349
 
1343
1350
  if self._capture and self.video_source:
1344
1351
  self._tasks.append(asyncio.create_task(self._video_loop()))
@@ -1688,6 +1695,7 @@ class LiveKitPublisher:
1688
1695
  except asyncio.CancelledError:
1689
1696
  raise
1690
1697
  except Exception as e:
1698
+ self._mic_error = str(e)
1691
1699
  logger.warning(
1692
1700
  f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
1693
1701
  )
@@ -1709,7 +1717,8 @@ class LiveKitPublisher:
1709
1717
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
1710
1718
  return
1711
1719
  self._mic_capture = mic
1712
- logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1720
+ self._mic_error = None
1721
+ logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1713
1722
  # Read in bigger batches (100ms) instead of one 20ms frame per
1714
1723
  # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
1715
1724
  # overhead (~30ms observed on this machine) that dominates when the
@@ -1949,46 +1958,92 @@ class AlsaAudioCapture(AudioCapture):
1949
1958
  """Capture Linux PCM through the same ALSA path used by onsite tests."""
1950
1959
 
1951
1960
  def __init__(self, device: str):
1961
+ import os
1952
1962
  import re
1963
+ import select
1953
1964
  import subprocess
1954
1965
  from media_lock import media_lock
1955
1966
 
1956
1967
  match = re.search(r"\b(hw:\d+,\d+)\b", device)
1957
1968
  if not match:
1958
1969
  raise ValueError(f"no ALSA hardware address in {device!r}")
1959
- alsa_device = f"plug{match.group(1)}"
1960
1970
  self.buffer = bytearray()
1961
- self.media_guard = media_lock("microphone", timeout=5.0).acquire()
1962
- try:
1963
- self.process = subprocess.Popen(
1964
- [
1965
- "arecord", "-q", "-D", alsa_device, "-t", "raw",
1966
- "-f", "S16_LE", "-r", "48000", "-c", "1",
1967
- ],
1968
- stdout=subprocess.PIPE,
1969
- stderr=subprocess.PIPE,
1970
- )
1971
- except Exception:
1972
- self.media_guard.release()
1973
- raise
1974
- if self.process.stdout is None:
1975
- self.process.kill()
1976
- self.process.wait(timeout=1)
1971
+ self.media_guard = media_lock("microphone", timeout=1.5).acquire()
1972
+ self.process = None
1973
+ self.source_rate = 48000
1974
+ self.rate_state = None
1975
+ errors = []
1976
+ # The fleet USB microphone normally accepts 48 kHz through ALSA's
1977
+ # plug layer. Some firmware revisions expose only native 44.1 kHz;
1978
+ # accept that rate and resample below rather than publishing silence.
1979
+ for source_rate in (48000, 44100):
1980
+ alsa_device = f"plug{match.group(1)}"
1981
+ process = None
1982
+ try:
1983
+ process = subprocess.Popen(
1984
+ [
1985
+ "arecord", "-q", "-D", alsa_device, "-t", "raw",
1986
+ "-f", "S16_LE", "-r", str(source_rate), "-c", "1",
1987
+ "--period-size", str(max(256, source_rate // 50)),
1988
+ ],
1989
+ stdout=subprocess.PIPE,
1990
+ stderr=subprocess.PIPE,
1991
+ bufsize=0,
1992
+ )
1993
+ if process.stdout is None:
1994
+ raise RuntimeError("arecord did not provide a PCM stream")
1995
+ ready, _, _ = select.select([process.stdout], [], [], 2.0)
1996
+ if not ready:
1997
+ if process.poll() is None:
1998
+ raise TimeoutError("arecord produced no PCM within 2 seconds")
1999
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2000
+ raise OSError(detail or f"arecord exited {process.returncode}")
2001
+ first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
2002
+ if not first:
2003
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2004
+ raise OSError(detail or "arecord returned an empty PCM frame")
2005
+ self.process = process
2006
+ self.source_rate = source_rate
2007
+ self.alsa_device = alsa_device
2008
+ if source_rate != 48000:
2009
+ import audioop
2010
+ first, self.rate_state = audioop.ratecv(
2011
+ first, 2, 1, source_rate, 48000, self.rate_state
2012
+ )
2013
+ self.buffer.extend(first)
2014
+ break
2015
+ except Exception as exc:
2016
+ errors.append(f"{source_rate}Hz: {exc}")
2017
+ if process is not None:
2018
+ if process.poll() is None:
2019
+ process.terminate()
2020
+ try:
2021
+ process.wait(timeout=1)
2022
+ except subprocess.TimeoutExpired:
2023
+ process.kill()
2024
+ process.wait(timeout=1)
2025
+ if self.process is None:
1977
2026
  self.media_guard.release()
1978
- raise RuntimeError("arecord did not provide a PCM stream")
1979
- self.alsa_device = alsa_device
2027
+ raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
1980
2028
 
1981
2029
  def read(self, samples_per_frame: int):
2030
+ import audioop
2031
+ import os
1982
2032
  from livekit import rtc
1983
2033
 
1984
2034
  bytes_needed = samples_per_frame * 2
2035
+ source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
1985
2036
  while len(self.buffer) < bytes_needed:
1986
- chunk = self.process.stdout.read(bytes_needed - len(self.buffer))
2037
+ chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
1987
2038
  if not chunk:
1988
2039
  detail = ""
1989
2040
  if self.process.stderr is not None:
1990
2041
  detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
1991
2042
  raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2043
+ if self.source_rate != 48000:
2044
+ chunk, self.rate_state = audioop.ratecv(
2045
+ chunk, 2, 1, self.source_rate, 48000, self.rate_state
2046
+ )
1992
2047
  self.buffer.extend(chunk)
1993
2048
  data = bytes(self.buffer[:bytes_needed])
1994
2049
  del self.buffer[:bytes_needed]