infinicode 2.8.96 → 2.8.98

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.
@@ -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]
@@ -403,7 +403,7 @@ def _post_supervisor_output(identity, kind: str, service: Optional[str], payload
403
403
  logger.debug(f"supervisor-output POST failed: {e}")
404
404
 
405
405
 
406
- def _handle_shell_command(identity, cmd: dict) -> None:
406
+ def _handle_shell_command(identity, cmd: dict) -> None:
407
407
  """Process a single shell/log request returned by the scheduler.
408
408
 
409
409
  `cmd` is {id, kind: "shell_run"|"tail_logs"|"speaker_test", service,
@@ -425,11 +425,68 @@ def _handle_shell_command(identity, cmd: dict) -> None:
425
425
  result = _tail_log_file(log_path, int(params.get("lines", 200)))
426
426
  elif kind == "shell_run":
427
427
  result = _run_shell_command(params.get("name", ""), params)
428
- elif kind == "speaker_test":
429
- result = _speaker_roundtrip_test(params)
428
+ elif kind == "speaker_test":
429
+ result = _speaker_roundtrip_test(params)
430
+ elif kind == "motor_sequence":
431
+ result = _run_motor_sequence(params)
430
432
  else:
431
433
  result = {"ok": False, "error": f"unknown shell command kind: {kind!r}"}
432
- _post_supervisor_output(identity, kind, service, result, request_id)
434
+ _post_supervisor_output(identity, kind, service, result, request_id)
435
+
436
+
437
+ def _run_motor_sequence(params: dict) -> dict:
438
+ """Run one validated scheduler sequence against the robot-local motor API."""
439
+ import httpx
440
+ base = str(params.get("motor_server_url") or "http://127.0.0.1:8001").rstrip("/")
441
+ if not (base.startswith("http://127.0.0.1:") or base.startswith("http://localhost:")):
442
+ return {"ok": False, "error": "motor server must be robot-local"}
443
+ registry = {str(item.get("id")): item for item in (params.get("registry") or [])}
444
+ steps = params.get("steps") or []
445
+ started = time.monotonic()
446
+ completed = []
447
+ try:
448
+ with httpx.Client(timeout=8.0) as client:
449
+ existing = client.get(f"{base}/list-motors").json().get("motors", [])
450
+ existing_names = {str(item.get("name")) for item in existing}
451
+ for motor_id, motor in registry.items():
452
+ body = {"name": motor_id, "gpio": int(motor["gpio"]), "active_high": bool(motor.get("active_high", True))}
453
+ response = (client.put(f"{base}/update-motor/{motor_id}", json=body)
454
+ if motor_id in existing_names else client.post(f"{base}/add-motor", json=body))
455
+ response.raise_for_status()
456
+ for index, step in enumerate(steps):
457
+ motor_id = str(step.get("motor_id"))
458
+ motor = registry.get(motor_id)
459
+ if not motor:
460
+ raise ValueError(f"step {index + 1} references unknown motor {motor_id}")
461
+ delay_ms = max(0, min(30000, int(step.get("delay_ms", 0))))
462
+ duration_ms = max(50, min(int(motor.get("max_duration_ms", 3000)), int(step.get("duration_ms", 500))))
463
+ if delay_ms:
464
+ time.sleep(delay_ms / 1000.0)
465
+ response = client.post(f"{base}/trigger-motor", json={"motor_name": motor_id, "seconds": duration_ms / 1000.0})
466
+ response.raise_for_status()
467
+ deadline = time.monotonic() + duration_ms / 1000.0 + 2.0
468
+ while time.monotonic() < deadline:
469
+ status_response = client.get(f"{base}/status")
470
+ status_response.raise_for_status()
471
+ status = status_response.json()
472
+ if status.get("status") == "idle":
473
+ if status.get("error"):
474
+ raise RuntimeError(f"motor {motor_id} failed: {status['error']}")
475
+ break
476
+ time.sleep(0.05)
477
+ else:
478
+ raise TimeoutError(f"motor {motor_id} did not return to idle")
479
+ completed.append({"motor_id": motor_id, "duration_ms": duration_ms})
480
+ except Exception as exc:
481
+ try:
482
+ httpx.post(f"{base}/stop-motors", json={}, timeout=3.0)
483
+ except Exception:
484
+ pass
485
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "completed_steps": completed,
486
+ "duration_ms": int((time.monotonic() - started) * 1000), "session_id": params.get("session_id")}
487
+ return {"ok": True, "pass": True, "sequence_id": params.get("sequence_id"), "completed_steps": completed,
488
+ "duration_ms": int((time.monotonic() - started) * 1000), "motor_server_url": base,
489
+ "session_id": params.get("session_id")}
433
490
 
434
491
 
435
492
  # ── Speaker roundtrip test (C2 gap-fill) ──
@@ -626,15 +683,92 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
626
683
  return False, f"{alsa_device}: {last_error}"
627
684
 
628
685
 
686
+ def _linux_mic_groundtruth(selected_input: str, duration: float = 4.0) -> dict:
687
+ """Capture the fleet USB mic through the exact onsite-proven ALSA path."""
688
+ import re
689
+
690
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_input), re.IGNORECASE)
691
+ if not match:
692
+ return {"ok": False, "error": "selected input has no ALSA hw address"}
693
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
694
+ seconds = max(1, min(10, int(round(duration))))
695
+ command = [
696
+ "arecord", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
697
+ "-r", "48000", "-c", "1", "-d", str(seconds),
698
+ ]
699
+ started = time.monotonic()
700
+ try:
701
+ from media_lock import media_lock
702
+ with media_lock("microphone", timeout=8.0):
703
+ completed = subprocess.run(
704
+ command,
705
+ stdout=subprocess.PIPE,
706
+ stderr=subprocess.PIPE,
707
+ timeout=seconds + 4.0,
708
+ check=False,
709
+ )
710
+ except Exception as exc:
711
+ return {
712
+ "ok": False,
713
+ "mode": "mic_groundtruth",
714
+ "error": f"{type(exc).__name__}: {exc}",
715
+ "input_device": alsa_device,
716
+ "duration_ms": int((time.monotonic() - started) * 1000),
717
+ }
718
+ raw = completed.stdout or b""
719
+ stderr = completed.stderr.decode("utf-8", errors="replace").strip()
720
+ if completed.returncode != 0 or len(raw) < 2:
721
+ return {
722
+ "ok": False,
723
+ "mode": "mic_groundtruth",
724
+ "error": stderr or f"arecord exited {completed.returncode}",
725
+ "exit_code": completed.returncode,
726
+ "bytes": len(raw),
727
+ "input_device": alsa_device,
728
+ "command": " ".join(command),
729
+ "duration_ms": int((time.monotonic() - started) * 1000),
730
+ }
731
+ sample_count = len(raw) // 2
732
+ peak = 0
733
+ square_sum = 0
734
+ nonzero = 0
735
+ for (value,) in struct.iter_unpack("<h", raw[:sample_count * 2]):
736
+ magnitude = abs(value)
737
+ peak = max(peak, magnitude)
738
+ square_sum += value * value
739
+ if value:
740
+ nonzero += 1
741
+ rms = (square_sum / sample_count) ** 0.5
742
+ peak_db = round(20.0 * math.log10(max(1, peak) / 32767.0), 1)
743
+ rms_db = round(20.0 * math.log10(max(1.0, rms) / 32767.0), 1)
744
+ return {
745
+ "ok": True,
746
+ "pass": nonzero > 0 and peak > 0,
747
+ "mode": "mic_groundtruth",
748
+ "exit_code": completed.returncode,
749
+ "bytes": len(raw),
750
+ "samples": sample_count,
751
+ "nonzero_samples": nonzero,
752
+ "nonzero_percent": round(nonzero * 100.0 / sample_count, 3),
753
+ "recorded_peak": peak,
754
+ "recorded_peak_db": peak_db,
755
+ "recorded_rms": round(rms, 2),
756
+ "recorded_rms_db": rms_db,
757
+ "sample_rate": 48000,
758
+ "channels": 1,
759
+ "input_device": alsa_device,
760
+ "command": " ".join(command),
761
+ "stderr": stderr,
762
+ "duration_ms": int((time.monotonic() - started) * 1000),
763
+ "diagnostic": "ground truth captured through the onsite-proven ALSA arecord path",
764
+ }
765
+
766
+
629
767
  def _speaker_roundtrip_test(params: dict) -> dict:
630
768
  """Play a test tone + record the mic simultaneously; return metrics.
631
769
 
632
770
  params may include {frequency, duration, amplitude, output, input,
633
771
  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
772
  mode = str(params.get("mode", "tone")).lower()
639
773
  playback_only = bool(params.get("playback_only", mode == "tts"))
640
774
  freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
@@ -646,8 +780,16 @@ def _speaker_roundtrip_test(params: dict) -> dict:
646
780
  out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
647
781
  in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
648
782
  source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
783
+ if mode == "mic_groundtruth":
784
+ if not sys.platform.startswith("linux"):
785
+ return {"ok": False, "error": "mic_groundtruth requires Linux ALSA"}
786
+ return _linux_mic_groundtruth(str(in_name), float(params.get("duration", 4.0)))
649
787
  if mode not in ("tone", "tts"):
650
788
  return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
789
+ try:
790
+ import pyaudio
791
+ except ImportError as e:
792
+ return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
651
793
  if mode == "tone" and not (50.0 <= freq <= 8000.0):
652
794
  return {"ok": False, "error": f"frequency {freq}Hz out of allowed range (50..8000)"}
653
795
  if not (0.1 <= dur <= 3.0):
@@ -936,7 +1078,7 @@ def run(config_path: Path) -> None:
936
1078
  _execute_command(target, cmd.get("action", "restart"))
937
1079
  elif kind == "supervisor_action":
938
1080
  logger.warning(f"remote command for unknown service {cmd.get('service_name')!r} ignored")
939
- elif kind in ("shell_run", "tail_logs", "speaker_test"):
1081
+ elif kind in ("shell_run", "tail_logs", "speaker_test", "motor_sequence"):
940
1082
  # run on a background thread so we don't block
941
1083
  # the 2s poll loop on a slow command
942
1084
  import threading