infinicode 2.8.96 → 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.
@@ -172,3 +172,9 @@ Required result: `playback_started ok`.
172
172
  resampling, and releases the microphone lock after every failed attempt.
173
173
  - Pipeline failures include the selected device and exact ALSA startup error.
174
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.96",
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
@@ -1733,7 +1778,6 @@ class LiveKitPublisher:
1733
1778
  samples_per_frame = int(48000 * frame_ms / 1000)
1734
1779
  samples_per_batch = int(48000 * BATCH_MS / 1000)
1735
1780
  bytes_per_frame = samples_per_frame * 2 # int16 mono
1736
- import audioop
1737
1781
  mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1738
1782
  _diag_peak = 0
1739
1783
  _diag_count = 0
@@ -1751,8 +1795,7 @@ class LiveKitPublisher:
1751
1795
  _pub_start = time.monotonic()
1752
1796
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1753
1797
  chunk = data[off:off + bytes_per_frame]
1754
- if mic_gain != 1.0:
1755
- chunk = audioop.mul(chunk, 2, mic_gain)
1798
+ chunk, p = _pcm16_scale_and_peak(chunk, mic_gain)
1756
1799
  frame = self.rtc.AudioFrame(
1757
1800
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1758
1801
  )
@@ -1763,8 +1806,7 @@ class LiveKitPublisher:
1763
1806
  "microphone_published", "ok",
1764
1807
  "Microphone track published with live PCM frames",
1765
1808
  )
1766
- p = audioop.max(chunk, 2)
1767
- _diag_peak = max(_diag_peak, p)
1809
+ _diag_peak = max(_diag_peak, p)
1768
1810
  _diag_count += 1
1769
1811
  _diag_read_ms += (_t1 - _t0) * 1000
1770
1812
  _diag_publish_ms += (time.monotonic() - _pub_start) * 1000
@@ -1971,7 +2013,6 @@ class AlsaAudioCapture(AudioCapture):
1971
2013
  self.media_guard = media_lock("microphone", timeout=1.5).acquire()
1972
2014
  self.process = None
1973
2015
  self.source_rate = 48000
1974
- self.rate_state = None
1975
2016
  errors = []
1976
2017
  # The fleet USB microphone normally accepts 48 kHz through ALSA's
1977
2018
  # plug layer. Some firmware revisions expose only native 44.1 kHz;
@@ -2006,10 +2047,7 @@ class AlsaAudioCapture(AudioCapture):
2006
2047
  self.source_rate = source_rate
2007
2048
  self.alsa_device = alsa_device
2008
2049
  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
- )
2050
+ first = _pcm16_resample_mono(first, source_rate, 48000)
2013
2051
  self.buffer.extend(first)
2014
2052
  break
2015
2053
  except Exception as exc:
@@ -2027,7 +2065,6 @@ class AlsaAudioCapture(AudioCapture):
2027
2065
  raise OSError(f"ALSA capture failed on plug{match.group(1)} ({'; '.join(errors)})")
2028
2066
 
2029
2067
  def read(self, samples_per_frame: int):
2030
- import audioop
2031
2068
  import os
2032
2069
  from livekit import rtc
2033
2070
 
@@ -2041,9 +2078,7 @@ class AlsaAudioCapture(AudioCapture):
2041
2078
  detail = self.process.stderr.read().decode("utf-8", errors="replace").strip()
2042
2079
  raise OSError(f"ALSA capture stopped on {self.alsa_device}: {detail or 'no PCM data'}")
2043
2080
  if self.source_rate != 48000:
2044
- chunk, self.rate_state = audioop.ratecv(
2045
- chunk, 2, 1, self.source_rate, 48000, self.rate_state
2046
- )
2081
+ chunk = _pcm16_resample_mono(chunk, self.source_rate, 48000)
2047
2082
  self.buffer.extend(chunk)
2048
2083
  data = bytes(self.buffer[:bytes_needed])
2049
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):