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
|
@@ -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",
|
|
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
|
-
|
|
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
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
)
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
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
|
|
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.
|
|
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]
|