infinicode 2.8.88 → 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.88",
3
+ "version": "2.8.89",
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",
@@ -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
- try:
89
- import pyaudio
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
- pa = pyaudio.PyAudio()
99
- device_index = None
100
- selected = str(selected_output or "default")
101
- try:
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
- for frequency, duration in notes:
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
- asyncio.create_task(
1045
- asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
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
- return True
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)