infinicode 2.8.87 → 2.8.89
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.
|
@@ -60,6 +60,10 @@ ROBOVOICE checkout, and rebuilds Compose once.
|
|
|
60
60
|
The preview agent previously used PyAudio for the selected speaker. BMW's
|
|
61
61
|
proven output is ALSA `aplay -D plughw:2,0`. Infinicode `2.8.86` uses that
|
|
62
62
|
same endpoint for live TTS whenever a Linux output selection contains `hw:`.
|
|
63
|
+
Motion cues must use that same ALSA endpoint and finish before the greeting
|
|
64
|
+
starts. Running a PyAudio cue concurrently with ALSA TTS creates an exclusive
|
|
65
|
+
device race on `hw:2,0`. `playback_started` is valid only after PCM has been
|
|
66
|
+
written to the physical speaker, not merely received from LiveKit.
|
|
63
67
|
|
|
64
68
|
### Silent Conversation After Greeting
|
|
65
69
|
|
|
@@ -70,6 +74,14 @@ for actual PCM frames before reporting `microphone_published`, reports a blocked
|
|
|
70
74
|
stage when capture does not start within five seconds, and applies bounded mic
|
|
71
75
|
gain before publication.
|
|
72
76
|
|
|
77
|
+
### Camera Relay With Multiple Viewers
|
|
78
|
+
|
|
79
|
+
RoboVision must have exactly one OpenCV camera reader. Preview, dashboard, and
|
|
80
|
+
motion detection consume a shared latest-frame stream. Starting one
|
|
81
|
+
`generate_frames()` camera loop per MJPEG client causes concurrent
|
|
82
|
+
`VideoCapture.read()` calls, which can block the second client and make the hub
|
|
83
|
+
relay time out with zero bytes.
|
|
84
|
+
|
|
73
85
|
### Duplicate Speaker Tests
|
|
74
86
|
|
|
75
87
|
Only one robot process may claim a scheduler `speaker_test`. The scheduler
|
package/package.json
CHANGED
|
@@ -83,22 +83,51 @@ def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
|
|
|
83
83
|
return url
|
|
84
84
|
|
|
85
85
|
|
|
86
|
-
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
87
|
-
"""Play a short local cue without involving the voice pipeline."""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
except Exception:
|
|
91
|
-
return
|
|
92
|
-
sample_rate = 48000
|
|
93
|
-
channels = 2
|
|
86
|
+
def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
87
|
+
"""Play a short local cue without involving the voice pipeline."""
|
|
88
|
+
sample_rate = 48000
|
|
89
|
+
channels = 2
|
|
94
90
|
if effect == "motion":
|
|
95
91
|
notes = ((880, 0.09), (1320, 0.13))
|
|
96
92
|
else:
|
|
97
93
|
notes = ((660, 0.10), (440, 0.16))
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
selected = str(selected_output or "default")
|
|
95
|
+
frames = bytearray()
|
|
96
|
+
for frequency, duration in notes:
|
|
97
|
+
count = int(sample_rate * duration)
|
|
98
|
+
for n in range(count):
|
|
99
|
+
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
100
|
+
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
101
|
+
frames.extend(struct.pack("<hh", value, value))
|
|
102
|
+
|
|
103
|
+
if sys.platform.startswith("linux") and "hw:" in selected:
|
|
104
|
+
import re
|
|
105
|
+
import subprocess
|
|
106
|
+
|
|
107
|
+
match = re.search(r"\b(hw:\d+,\d+)\b", selected)
|
|
108
|
+
if not match:
|
|
109
|
+
return
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
[
|
|
112
|
+
"aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
|
|
113
|
+
"-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
|
|
114
|
+
],
|
|
115
|
+
input=bytes(frames), capture_output=True, timeout=3.0,
|
|
116
|
+
)
|
|
117
|
+
if result.returncode:
|
|
118
|
+
logger.warning(
|
|
119
|
+
"audio effect failed on %s: %s",
|
|
120
|
+
match.group(1), result.stderr.decode("utf-8", errors="replace").strip(),
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
import pyaudio
|
|
126
|
+
except Exception:
|
|
127
|
+
return
|
|
128
|
+
pa = pyaudio.PyAudio()
|
|
129
|
+
device_index = None
|
|
130
|
+
try:
|
|
102
131
|
if selected.strip().isdigit():
|
|
103
132
|
device_index = int(selected.strip())
|
|
104
133
|
elif selected.lower() == "default":
|
|
@@ -118,14 +147,7 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
|
|
|
118
147
|
output=True,
|
|
119
148
|
output_device_index=device_index,
|
|
120
149
|
)
|
|
121
|
-
|
|
122
|
-
count = int(sample_rate * duration)
|
|
123
|
-
frames = bytearray()
|
|
124
|
-
for n in range(count):
|
|
125
|
-
envelope = min(1.0, n / 240.0, (count - n) / 1200.0)
|
|
126
|
-
value = int(5000 * envelope * math.sin(2 * math.pi * frequency * n / sample_rate))
|
|
127
|
-
frames.extend(struct.pack("<hh", value, value))
|
|
128
|
-
stream.write(bytes(frames))
|
|
150
|
+
stream.write(bytes(frames))
|
|
129
151
|
stream.stop_stream()
|
|
130
152
|
stream.close()
|
|
131
153
|
except Exception as e:
|
|
@@ -1041,9 +1063,10 @@ class PreviewAgent:
|
|
|
1041
1063
|
# for the LiveKit publisher.
|
|
1042
1064
|
await asyncio.sleep(0.5)
|
|
1043
1065
|
# I/O. Camera/mic join and the preset greeting are the critical path.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1066
|
+
# The cue and TTS share one physical ALSA output. Finish and release
|
|
1067
|
+
# the cue before LiveKit can deliver the greeting; background playback
|
|
1068
|
+
# races aplay and makes greeting delivery intermittently fail busy.
|
|
1069
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1047
1070
|
try:
|
|
1048
1071
|
r = await self._session.post(
|
|
1049
1072
|
f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/request-session",
|
|
@@ -1350,17 +1373,27 @@ class LiveKitPublisher:
|
|
|
1350
1373
|
# steady pace, independent of how unevenly frames actually arrive.
|
|
1351
1374
|
import queue
|
|
1352
1375
|
import threading
|
|
1353
|
-
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1354
|
-
written_frames = [0]
|
|
1355
|
-
PREBUFFER_CHUNKS = 5
|
|
1356
|
-
stream_failed = threading.Event()
|
|
1376
|
+
write_queue: "queue.Queue[Optional[bytes]]" = queue.Queue()
|
|
1377
|
+
written_frames = [0]
|
|
1378
|
+
PREBUFFER_CHUNKS = 5
|
|
1379
|
+
stream_failed = threading.Event()
|
|
1380
|
+
playback_reported = threading.Event()
|
|
1381
|
+
event_loop = asyncio.get_running_loop()
|
|
1357
1382
|
|
|
1358
1383
|
def _safe_write(chunk: bytes) -> bool:
|
|
1359
1384
|
if stream_failed.is_set():
|
|
1360
1385
|
return False
|
|
1361
|
-
try:
|
|
1362
|
-
out.write(chunk)
|
|
1363
|
-
|
|
1386
|
+
try:
|
|
1387
|
+
out.write(chunk)
|
|
1388
|
+
if not playback_reported.is_set():
|
|
1389
|
+
playback_reported.set()
|
|
1390
|
+
asyncio.run_coroutine_threadsafe(
|
|
1391
|
+
self.agent._report_pipeline(
|
|
1392
|
+
"playback_started", "ok", "First TTS audio chunk written to robot speaker"
|
|
1393
|
+
),
|
|
1394
|
+
event_loop,
|
|
1395
|
+
)
|
|
1396
|
+
return True
|
|
1364
1397
|
except Exception as e:
|
|
1365
1398
|
stream_failed.set()
|
|
1366
1399
|
logger.warning(f"audio out stream closed; disabling playback for this track: {e}")
|
|
@@ -1420,7 +1453,6 @@ class LiveKitPublisher:
|
|
|
1420
1453
|
f"audio out: first frame received for {sid} "
|
|
1421
1454
|
f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
|
|
1422
1455
|
)
|
|
1423
|
-
await self.agent._report_pipeline("playback_started", "ok", "First TTS audio frame reached the robot")
|
|
1424
1456
|
in_rate = af.sample_rate
|
|
1425
1457
|
in_channels = int(getattr(af, "num_channels", 1) or 1)
|
|
1426
1458
|
raw = bytes(af.data)
|
|
@@ -18,6 +18,11 @@ CORS(app)
|
|
|
18
18
|
# Camera management
|
|
19
19
|
current_camera_index = 0
|
|
20
20
|
camera = None
|
|
21
|
+
camera_lock = threading.Lock()
|
|
22
|
+
frame_condition = threading.Condition()
|
|
23
|
+
latest_frame_bytes = None
|
|
24
|
+
latest_frame_sequence = 0
|
|
25
|
+
camera_worker_started = False
|
|
21
26
|
audio_input_device = "default"
|
|
22
27
|
audio_output_device = "default"
|
|
23
28
|
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
|
|
@@ -224,22 +229,25 @@ def send_webhook(frame_data):
|
|
|
224
229
|
except Exception as e:
|
|
225
230
|
print(f"Error preparing webhook: {e}")
|
|
226
231
|
|
|
227
|
-
def
|
|
228
|
-
global latest_detections, motion_detection_active, motion_detected_state
|
|
229
|
-
global last_motion_time, motion_frame_buffer
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
232
|
+
def camera_worker():
|
|
233
|
+
global latest_detections, motion_detection_active, motion_detected_state
|
|
234
|
+
global last_motion_time, motion_frame_buffer
|
|
235
|
+
global latest_frame_bytes, latest_frame_sequence
|
|
236
|
+
|
|
237
|
+
prev_gray = None
|
|
238
|
+
|
|
239
|
+
while True:
|
|
240
|
+
with camera_lock:
|
|
241
|
+
cam = get_camera()
|
|
242
|
+
if cam is None or not cam.isOpened():
|
|
243
|
+
time.sleep(1)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
with camera_lock:
|
|
247
|
+
success, frame = cam.read()
|
|
248
|
+
if not success:
|
|
249
|
+
time.sleep(0.1)
|
|
250
|
+
continue
|
|
243
251
|
|
|
244
252
|
# Run object detection
|
|
245
253
|
processed_frame, detections = detect_objects(frame, conf_threshold=0.5)
|
|
@@ -288,11 +296,40 @@ def generate_frames():
|
|
|
288
296
|
|
|
289
297
|
prev_gray = gray
|
|
290
298
|
|
|
291
|
-
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
299
|
+
ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
|
300
|
+
if not ret:
|
|
301
|
+
continue
|
|
302
|
+
with frame_condition:
|
|
303
|
+
latest_frame_bytes = buffer.tobytes()
|
|
304
|
+
latest_frame_sequence += 1
|
|
305
|
+
frame_condition.notify_all()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _ensure_camera_worker():
|
|
309
|
+
global camera_worker_started
|
|
310
|
+
with frame_condition:
|
|
311
|
+
if camera_worker_started:
|
|
312
|
+
return
|
|
313
|
+
camera_worker_started = True
|
|
314
|
+
threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def generate_frames():
|
|
318
|
+
_ensure_camera_worker()
|
|
319
|
+
sequence = -1
|
|
320
|
+
while True:
|
|
321
|
+
with frame_condition:
|
|
322
|
+
frame_condition.wait_for(
|
|
323
|
+
lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
|
|
324
|
+
timeout=5.0,
|
|
325
|
+
)
|
|
326
|
+
if latest_frame_bytes is None or latest_frame_sequence == sequence:
|
|
327
|
+
continue
|
|
328
|
+
frame_bytes = latest_frame_bytes
|
|
329
|
+
sequence = latest_frame_sequence
|
|
330
|
+
|
|
331
|
+
yield (b'--frame\r\n'
|
|
332
|
+
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
|
296
333
|
|
|
297
334
|
@app.route('/')
|
|
298
335
|
def index():
|
|
@@ -379,11 +416,11 @@ def switch_camera():
|
|
|
379
416
|
if isinstance(new_index, str) and new_index.isdigit():
|
|
380
417
|
new_index = int(new_index)
|
|
381
418
|
|
|
382
|
-
|
|
383
|
-
camera
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
419
|
+
with camera_lock:
|
|
420
|
+
if camera:
|
|
421
|
+
camera.release()
|
|
422
|
+
current_camera_index = new_index
|
|
423
|
+
camera = None
|
|
387
424
|
|
|
388
425
|
return jsonify({"status": "ok", "camera_index": current_camera_index})
|
|
389
426
|
|