infinicode 2.8.95 → 2.8.97

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,21 @@ 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.
175
+ - Remote `speaker-test` requests support `mode=mic_groundtruth`, which captures
176
+ through the same `arecord -D plughw:X,Y -f S16_LE -r 48000 -c 1` command
177
+ proven onsite and returns bytes, RMS, peak, and nonzero sample counts.
178
+ - Python 3.13 removed `audioop`. Version 2.8.97 replaces microphone gain, peak
179
+ measurement, and fallback resampling with internal PCM16 helpers; production
180
+ capture must not import `audioop`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.95",
3
+ "version": "2.8.97",
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",
@@ -97,7 +97,7 @@ def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> s
97
97
  return str(match.get("name")) if match and match.get("name") else selected_text
98
98
 
99
99
 
100
- def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
100
+ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
101
101
  """Avoid Windows localhost IPv6/IPv4 ambiguity for local LiveKit."""
102
102
  if not url:
103
103
  return url
@@ -105,9 +105,54 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
105
105
  prefix = f"{scheme}://localhost"
106
106
  if url.startswith(prefix):
107
107
  return f"{scheme}://127.0.0.1" + url[len(prefix):]
108
- return url
109
-
110
-
108
+ return url
109
+
110
+
111
+ def _pcm16_scale_and_peak(data: bytes, gain: float) -> tuple[bytes, int]:
112
+ """Apply gain and measure peak without audioop (removed in Python 3.13)."""
113
+ from array import array
114
+
115
+ samples = array("h")
116
+ samples.frombytes(data[:len(data) - (len(data) % 2)])
117
+ if sys.byteorder != "little":
118
+ samples.byteswap()
119
+ peak = 0
120
+ for index, value in enumerate(samples):
121
+ scaled = max(-32768, min(32767, int(value * gain))) if gain != 1.0 else value
122
+ samples[index] = scaled
123
+ peak = max(peak, abs(scaled))
124
+ if sys.byteorder != "little":
125
+ samples.byteswap()
126
+ return samples.tobytes(), peak
127
+
128
+
129
+ def _pcm16_resample_mono(data: bytes, source_rate: int, target_rate: int) -> bytes:
130
+ """Linearly resample a PCM16 mono chunk using only the standard library."""
131
+ from array import array
132
+
133
+ if source_rate == target_rate or len(data) < 4:
134
+ return data
135
+ source = array("h")
136
+ source.frombytes(data[:len(data) - (len(data) % 2)])
137
+ if sys.byteorder != "little":
138
+ source.byteswap()
139
+ target_count = max(1, round(len(source) * target_rate / source_rate))
140
+ target = array("h", [0]) * target_count
141
+ scale = source_rate / target_rate
142
+ last = len(source) - 1
143
+ for index in range(target_count):
144
+ position = min(last, index * scale)
145
+ left = int(position)
146
+ right = min(last, left + 1)
147
+ fraction = position - left
148
+ target[index] = max(-32768, min(32767, round(
149
+ source[left] + (source[right] - source[left]) * fraction
150
+ )))
151
+ if sys.byteorder != "little":
152
+ target.byteswap()
153
+ return target.tobytes()
154
+
155
+
111
156
  def _play_audio_effect(selected_output: str | None, effect: str) -> None:
112
157
  """Play a short local cue without involving the voice pipeline."""
113
158
  sample_rate = 48000
@@ -1238,6 +1283,7 @@ class LiveKitPublisher:
1238
1283
  self._capture: Optional["VideoCapture"] = None
1239
1284
  self._mic_capture: Optional["AudioCapture"] = None
1240
1285
  self._mic_streaming = asyncio.Event()
1286
+ self._mic_error: Optional[str] = None
1241
1287
  # Motion sampling state belongs to the publisher instance. Keeping it
1242
1288
  # initialized here prevents shutdown/reopen paths from raising while
1243
1289
  # the camera is being handed between preview and voice sessions.
@@ -1335,10 +1381,16 @@ class LiveKitPublisher:
1335
1381
  try:
1336
1382
  await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1337
1383
  except asyncio.TimeoutError:
1384
+ detail = self._mic_error or f"no PCM received from {self.agent.audio_capture_device}"
1338
1385
  await self.agent._report_pipeline(
1339
- "microphone_published", "blocked", "Microphone track published but PCM capture did not start"
1386
+ "microphone_published", "blocked",
1387
+ f"Microphone track published but PCM capture did not start: {detail}",
1388
+ {"device": self.agent.audio_capture_device, "error": detail},
1389
+ )
1390
+ logger.warning(
1391
+ "microphone PCM is not ready (%s); keeping the session alive while capture retries",
1392
+ detail,
1340
1393
  )
1341
- logger.warning("microphone PCM is not ready; keeping the session alive while capture retries")
1342
1394
 
1343
1395
  if self._capture and self.video_source:
1344
1396
  self._tasks.append(asyncio.create_task(self._video_loop()))
@@ -1688,6 +1740,7 @@ class LiveKitPublisher:
1688
1740
  except asyncio.CancelledError:
1689
1741
  raise
1690
1742
  except Exception as e:
1743
+ self._mic_error = str(e)
1691
1744
  logger.warning(
1692
1745
  f"audio_loop: capture failed on {self.agent.audio_capture_device}: {e}; retrying"
1693
1746
  )
@@ -1709,7 +1762,8 @@ class LiveKitPublisher:
1709
1762
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
1710
1763
  return
1711
1764
  self._mic_capture = mic
1712
- logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1765
+ self._mic_error = None
1766
+ logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1713
1767
  # Read in bigger batches (100ms) instead of one 20ms frame per
1714
1768
  # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed
1715
1769
  # overhead (~30ms observed on this machine) that dominates when the
@@ -1724,7 +1778,6 @@ class LiveKitPublisher:
1724
1778
  samples_per_frame = int(48000 * frame_ms / 1000)
1725
1779
  samples_per_batch = int(48000 * BATCH_MS / 1000)
1726
1780
  bytes_per_frame = samples_per_frame * 2 # int16 mono
1727
- import audioop
1728
1781
  mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1729
1782
  _diag_peak = 0
1730
1783
  _diag_count = 0
@@ -1742,8 +1795,7 @@ class LiveKitPublisher:
1742
1795
  _pub_start = time.monotonic()
1743
1796
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1744
1797
  chunk = data[off:off + bytes_per_frame]
1745
- if mic_gain != 1.0:
1746
- chunk = audioop.mul(chunk, 2, mic_gain)
1798
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1747
1799
  frame = self.rtc.AudioFrame(
1748
1800
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1749
1801
  )
@@ -1754,8 +1806,7 @@ class LiveKitPublisher:
1754
1806
  "microphone_published", "ok",
1755
1807
  "Microphone track published with live PCM frames",
1756
1808
  )
1757
- p = audioop.max(chunk, 2)
1758
- _diag_peak = max(_diag_peak, p)
1809
+ _diag_peak = max(_diag_peak, p)
1759
1810
  _diag_count += 1
1760
1811
  _diag_read_ms += (_t1 - _t0) * 1000
1761
1812
  _diag_publish_ms += (time.monotonic() - _pub_start) * 1000
@@ -1949,46 +2000,85 @@ class AlsaAudioCapture(AudioCapture):
1949
2000
  """Capture Linux PCM through the same ALSA path used by onsite tests."""
1950
2001
 
1951
2002
  def __init__(self, device: str):
2003
+ import os
1952
2004
  import re
2005
+ import select
1953
2006
  import subprocess
1954
2007
  from media_lock import media_lock
1955
2008
 
1956
2009
  match = re.search(r"\b(hw:\d+,\d+)\b", device)
1957
2010
  if not match:
1958
2011
  raise ValueError(f"no ALSA hardware address in {device!r}")
1959
- alsa_device = f"plug{match.group(1)}"
1960
2012
  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)
2013
+ self.media_guard = media_lock("microphone", timeout=1.5).acquire()
2014
+ self.process = None
2015
+ self.source_rate = 48000
2016
+ errors = []
2017
+ # The fleet USB microphone normally accepts 48 kHz through ALSA's
2018
+ # plug layer. Some firmware revisions expose only native 44.1 kHz;
2019
+ # accept that rate and resample below rather than publishing silence.
2020
+ for source_rate in (48000, 44100):
2021
+ alsa_device = f"plug{match.group(1)}"
2022
+ process = None
2023
+ try:
2024
+ process = subprocess.Popen(
2025
+ [
2026
+ "arecord", "-q", "-D", alsa_device, "-t", "raw",
2027
+ "-f", "S16_LE", "-r", str(source_rate), "-c", "1",
2028
+ "--period-size", str(max(256, source_rate // 50)),
2029
+ ],
2030
+ stdout=subprocess.PIPE,
2031
+ stderr=subprocess.PIPE,
2032
+ bufsize=0,
2033
+ )
2034
+ if process.stdout is None:
2035
+ raise RuntimeError("arecord did not provide a PCM stream")
2036
+ ready, _, _ = select.select([process.stdout], [], [], 2.0)
2037
+ if not ready:
2038
+ if process.poll() is None:
2039
+ raise TimeoutError("arecord produced no PCM within 2 seconds")
2040
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2041
+ raise OSError(detail or f"arecord exited {process.returncode}")
2042
+ first = os.read(process.stdout.fileno(), max(2048, source_rate // 25 * 2))
2043
+ if not first:
2044
+ detail = process.stderr.read().decode("utf-8", errors="replace").strip() if process.stderr else ""
2045
+ raise OSError(detail or "arecord returned an empty PCM frame")
2046
+ self.process = process
2047
+ self.source_rate = source_rate
2048
+ self.alsa_device = alsa_device
2049
+ if source_rate != 48000:
2050
+ first = _pcm16_resample_mono(first, source_rate, 48000)
2051
+ self.buffer.extend(first)
2052
+ break
2053
+ except Exception as exc:
2054
+ errors.append(f"{source_rate}Hz: {exc}")
2055
+ if process is not None:
2056
+ if process.poll() is None:
2057
+ process.terminate()
2058
+ try:
2059
+ process.wait(timeout=1)
2060
+ except subprocess.TimeoutExpired:
2061
+ process.kill()
2062
+ process.wait(timeout=1)
2063
+ if self.process is None:
1977
2064
  self.media_guard.release()
1978
- raise RuntimeError("arecord did not provide a PCM stream")
1979
- self.alsa_device = alsa_device
2065
+ raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
1980
2066
 
1981
2067
  def read(self, samples_per_frame: int):
2068
+ import os
1982
2069
  from livekit import rtc
1983
2070
 
1984
2071
  bytes_needed = samples_per_frame * 2
2072
+ source_bytes_needed = max(2, int(samples_per_frame * self.source_rate / 48000) * 2)
1985
2073
  while len(self.buffer) < bytes_needed:
1986
- chunk = self.process.stdout.read(bytes_needed - len(self.buffer))
2074
+ chunk = os.read(self.process.stdout.fileno(), source_bytes_needed)
1987
2075
  if not chunk:
1988
2076
  detail = ""
1989
2077
  if self.process.stderr is not None:
1990
2078
  detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
1991
2079
  raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2080
+ if self.source_rate != 48000:
2081
+ chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
1992
2082
  self.buffer.extend(chunk)
1993
2083
  data = bytes(self.buffer[:bytes_needed])
1994
2084
  del self.buffer[:bytes_needed]
@@ -626,15 +626,92 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
626
626
  return False, f"{alsa_device}: {last_error}"
627
627
 
628
628
 
629
+ def _linux_mic_groundtruth(selected_input: str, duration: float = 4.0) -> dict:
630
+ """Capture the fleet USB mic through the exact onsite-proven ALSA path."""
631
+ import re
632
+
633
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_input), re.IGNORECASE)
634
+ if not match:
635
+ return {"ok": False, "error": "selected input has no ALSA hw address"}
636
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
637
+ seconds = max(1, min(10, int(round(duration))))
638
+ command = [
639
+ "arecord", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
640
+ "-r", "48000", "-c", "1", "-d", str(seconds),
641
+ ]
642
+ started = time.monotonic()
643
+ try:
644
+ from media_lock import media_lock
645
+ with media_lock("microphone", timeout=8.0):
646
+ completed = subprocess.run(
647
+ command,
648
+ stdout=subprocess.PIPE,
649
+ stderr=subprocess.PIPE,
650
+ timeout=seconds + 4.0,
651
+ check=False,
652
+ )
653
+ except Exception as exc:
654
+ return {
655
+ "ok": False,
656
+ "mode": "mic_groundtruth",
657
+ "error": f"{type(exc).__name__}: {exc}",
658
+ "input_device": alsa_device,
659
+ "duration_ms": int((time.monotonic() - started) * 1000),
660
+ }
661
+ raw = completed.stdout or b""
662
+ stderr = completed.stderr.decode("utf-8", errors="replace").strip()
663
+ if completed.returncode != 0 or len(raw) < 2:
664
+ return {
665
+ "ok": False,
666
+ "mode": "mic_groundtruth",
667
+ "error": stderr or f"arecord exited {completed.returncode}",
668
+ "exit_code": completed.returncode,
669
+ "bytes": len(raw),
670
+ "input_device": alsa_device,
671
+ "command": " ".join(command),
672
+ "duration_ms": int((time.monotonic() - started) * 1000),
673
+ }
674
+ sample_count = len(raw) // 2
675
+ peak = 0
676
+ square_sum = 0
677
+ nonzero = 0
678
+ for (value,) in struct.iter_unpack("<h", raw[:sample_count * 2]):
679
+ magnitude = abs(value)
680
+ peak = max(peak, magnitude)
681
+ square_sum += value * value
682
+ if value:
683
+ nonzero += 1
684
+ rms = (square_sum / sample_count) ** 0.5
685
+ peak_db = round(20.0 * math.log10(max(1, peak) / 32767.0), 1)
686
+ rms_db = round(20.0 * math.log10(max(1.0, rms) / 32767.0), 1)
687
+ return {
688
+ "ok": True,
689
+ "pass": nonzero > 0 and peak > 0,
690
+ "mode": "mic_groundtruth",
691
+ "exit_code": completed.returncode,
692
+ "bytes": len(raw),
693
+ "samples": sample_count,
694
+ "nonzero_samples": nonzero,
695
+ "nonzero_percent": round(nonzero * 100.0 / sample_count, 3),
696
+ "recorded_peak": peak,
697
+ "recorded_peak_db": peak_db,
698
+ "recorded_rms": round(rms, 2),
699
+ "recorded_rms_db": rms_db,
700
+ "sample_rate": 48000,
701
+ "channels": 1,
702
+ "input_device": alsa_device,
703
+ "command": " ".join(command),
704
+ "stderr": stderr,
705
+ "duration_ms": int((time.monotonic() - started) * 1000),
706
+ "diagnostic": "ground truth captured through the onsite-proven ALSA arecord path",
707
+ }
708
+
709
+
629
710
  def _speaker_roundtrip_test(params: dict) -> dict:
630
711
  """Play a test tone + record the mic simultaneously; return metrics.
631
712
 
632
713
  params may include {frequency, duration, amplitude, output, input,
633
714
  threshold_db}. Anything missing falls back to the module constants."""
634
- try:
635
- import pyaudio
636
- except ImportError as e:
637
- return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
638
715
  mode = str(params.get("mode", "tone")).lower()
639
716
  playback_only = bool(params.get("playback_only", mode == "tts"))
640
717
  freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
@@ -646,8 +723,16 @@ def _speaker_roundtrip_test(params: dict) -> dict:
646
723
  out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
647
724
  in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
648
725
  source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
726
+ if mode == "mic_groundtruth":
727
+ if not sys.platform.startswith("linux"):
728
+ return {"ok": False, "error": "mic_groundtruth requires Linux ALSA"}
729
+ return _linux_mic_groundtruth(str(in_name), float(params.get("duration", 4.0)))
649
730
  if mode not in ("tone", "tts"):
650
731
  return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
732
+ try:
733
+ import pyaudio
734
+ except ImportError as e:
735
+ return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
651
736
  if mode == "tone" and not (50.0 <= freq <= 8000.0):
652
737
  return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
653
738
  if not (0.1 <= dur <= 3.0):