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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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):
|