infinicode 2.8.86 → 2.8.88

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.
@@ -18,7 +18,7 @@
18
18
  * registration mechanics is reimplemented here.
19
19
  */
20
20
  import { spawn, execFile } from 'node:child_process';
21
- import { existsSync } from 'node:fs';
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { hostname, networkInterfaces } from 'node:os';
23
23
  import { join } from 'node:path';
24
24
  import chalk from 'chalk';
@@ -103,6 +103,48 @@ function validateComposePath(path) {
103
103
  throw new Error(`no docker-compose.yaml/.yml found in '${path}'. Pass --path pointing at a ROBOVOICE checkout ` +
104
104
  `(the directory containing docker-compose.yaml), not this repo.`);
105
105
  }
106
+ /** Persist the single-track greeting fix in a ROBOVOICE checkout. */
107
+ function ensureRobovoiceSingleAudioTrack(path) {
108
+ const candidates = [
109
+ join(path, 'voice_agent.py'),
110
+ join(path, 'app', 'voice_agent.py'),
111
+ join(path, 'src', 'voice_agent.py'),
112
+ ];
113
+ const file = candidates.find((candidate) => existsSync(candidate));
114
+ if (!file) {
115
+ return { patched: false, warning: 'voice_agent.py was not found; skipping RoboPark greeting compatibility patch' };
116
+ }
117
+ const source = readFileSync(file, 'utf8');
118
+ if (source.includes('Initial greeting completed through AgentSession audio track')) {
119
+ return { patched: false, file };
120
+ }
121
+ const startMarker = ' # Synthesize the greeting first';
122
+ const endMarker = ' except Exception as e:';
123
+ const start = source.indexOf(startMarker);
124
+ const end = start >= 0 ? source.indexOf(endMarker, start) : -1;
125
+ if (start < 0 || end < 0) {
126
+ return {
127
+ patched: false,
128
+ file,
129
+ warning: 'voice_agent.py does not contain the known duplicate greeting-track block; no automatic patch applied',
130
+ };
131
+ }
132
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
133
+ const replacement = [
134
+ ' # Use the existing conversation track. A dedicated greeting',
135
+ ' # track makes Linux robots compete for the same ALSA device.',
136
+ ' import inspect as _inspect',
137
+ ' speech = session.say(greeting, allow_interruptions=False)',
138
+ ' if _inspect.isawaitable(speech):',
139
+ ' await speech',
140
+ " elif hasattr(speech, 'wait_for_playout'):",
141
+ ' await speech.wait_for_playout()',
142
+ " logger.info('Initial greeting completed through AgentSession audio track')",
143
+ '',
144
+ ].join(newline);
145
+ writeFileSync(file, source.slice(0, start) + replacement + source.slice(end), 'utf8');
146
+ return { patched: true, file };
147
+ }
106
148
  export async function roboparkAgentUp(opts) {
107
149
  console.log(chalk.bold('\n robopark agent up'));
108
150
  console.log(chalk.dim(' ' + '─'.repeat(52)));
@@ -125,8 +167,18 @@ export async function roboparkAgentUp(opts) {
125
167
  console.log(chalk.dim(` using: ${compose.cmd} ${compose.baseArgs.join(' ')}`.trimEnd()));
126
168
  console.log(` path: ${chalk.cyan(opts.path)}`);
127
169
  console.log();
170
+ const greetingPatch = ensureRobovoiceSingleAudioTrack(opts.path);
171
+ if (greetingPatch.patched) {
172
+ console.log(chalk.green(` ✓ persisted single-track RoboPark greeting in ${greetingPatch.file}`));
173
+ }
174
+ else if (greetingPatch.warning) {
175
+ console.log(chalk.yellow(` ⚠ ${greetingPatch.warning}`));
176
+ }
128
177
  console.log(chalk.bold(' 1. starting ROBOVOICE docker stack (docker compose up -d)…'));
129
- const upCode = await runCompose(compose, ['up', '-d'], opts.path);
178
+ // Rebuild only when compatibility source changed, making the fix survive
179
+ // container recreation without slowing normal restarts.
180
+ const upArgs = greetingPatch.patched ? ['up', '-d', '--build'] : ['up', '-d'];
181
+ const upCode = await runCompose(compose, upArgs, opts.path);
130
182
  if (upCode !== 0) {
131
183
  console.log(chalk.red(`\n ✗ docker compose up -d exited with code ${upCode}`));
132
184
  process.exit(upCode);
@@ -0,0 +1,119 @@
1
+ # RoboPark Production Memory
2
+
3
+ Last verified: 2026-07-16
4
+
5
+ This records production facts future operators and coding agents must
6
+ preserve. Never add enrollment tokens, mesh tokens, API keys, or device tokens.
7
+
8
+ ## Verified Architecture
9
+
10
+ - `livekit-1` is the Windows hub.
11
+ - Mesh/dashboard: TCP `47913`.
12
+ - RoboPark scheduler: TCP `8080`, proxied under `/robopark`.
13
+ - LiveKit: TCP `7880`.
14
+ - Production voice worker container: `caal-agent`.
15
+ - BMW maps to device `dev_0422e545` and character `vixen` / `VIXEN (BMW)`.
16
+ - Robots publish camera and microphone tracks to LiveKit. Park camera preview
17
+ uses the RoboVision relay rather than LiveKit video.
18
+
19
+ ## BMW Launch Device Lock
20
+
21
+ - Camera: `/dev/video0` (`H65 USB CAMERA`).
22
+ - Microphone: scheduler ID `2`, `Usb Audio Device: USB Audio (hw:3,0)`.
23
+ - Speaker: scheduler ID `1`, `USB Audio Device: - (hw:2,0)`.
24
+ - Linux live playback must use ALSA `plughw:2,0` through `aplay`.
25
+ - Production mic gain defaults to `ROBOPARK_MIC_GAIN=4.0` and is bounded to
26
+ `1.0..12.0` before PCM frames enter LiveKit.
27
+ - RoboVision/sounddevice numeric IDs are not PyAudio numeric IDs.
28
+ - Do not use HDMI or metadata-only `/dev/video*` devices as launch defaults.
29
+
30
+ ## Verified Production Flow
31
+
32
+ BMW passed these measured stages in one production session:
33
+
34
+ 1. `scheduler_session`
35
+ 2. `livekit_join`
36
+ 3. `tts_subscribed`
37
+ 4. `playback_started`
38
+ 5. `camera_published`
39
+ 6. `microphone_published`
40
+ 7. `robot_online`
41
+ 8. `camera_ready`
42
+ 9. `microphone_ready`
43
+ 10. `speaker_ready`
44
+
45
+ `playback_started` means the first remote TTS frame reached the robot. Never
46
+ report end-to-end audio delivery without this event.
47
+
48
+ ## Root Causes Fixed
49
+
50
+ ### Silent Greeting
51
+
52
+ The old ROBOVOICE greeting published a second dedicated LiveKit audio track.
53
+ The Pi subscribed to both tracks and attempted to open the same speaker twice.
54
+ ROBOVOICE must call `session.say(...)` so greeting and conversation share one
55
+ track. `robopark agent-up` now detects the old source block, patches the
56
+ ROBOVOICE checkout, and rebuilds Compose once.
57
+
58
+ ### Linux Speaker Playback
59
+
60
+ The preview agent previously used PyAudio for the selected speaker. BMW's
61
+ proven output is ALSA `aplay -D plughw:2,0`. Infinicode `2.8.86` uses that
62
+ same endpoint for live TTS whenever a Linux output selection contains `hw:`.
63
+
64
+ ### Silent Conversation After Greeting
65
+
66
+ Publishing a LiveKit microphone track does not prove that PCM capture started.
67
+ On Linux the production capture path is `arecord -D plughw:3,0 -f S16_LE -r
68
+ 48000 -c 1`; it must work even when PyAudio is unavailable. The Pi now waits
69
+ for actual PCM frames before reporting `microphone_published`, reports a blocked
70
+ stage when capture does not start within five seconds, and applies bounded mic
71
+ gain before publication.
72
+
73
+ ### Camera Relay With Multiple Viewers
74
+
75
+ RoboVision must have exactly one OpenCV camera reader. Preview, dashboard, and
76
+ motion detection consume a shared latest-frame stream. Starting one
77
+ `generate_frames()` camera loop per MJPEG client causes concurrent
78
+ `VideoCapture.read()` calls, which can block the second client and make the hub
79
+ relay time out with zero bytes.
80
+
81
+ ### Duplicate Speaker Tests
82
+
83
+ Only one robot process may claim a scheduler `speaker_test`. The scheduler
84
+ uses an atomic lease, and supervisor-status queries exclude speaker-test rows.
85
+
86
+ ### BMW Alias Configuration
87
+
88
+ Scheduler routes canonicalize alias `bmw` to `dev_0422e545` before reading
89
+ character, voice-stack, and greeting configuration.
90
+
91
+ ## Operational Rules
92
+
93
+ - Run one unified robot runtime per robot. Do not manually start preview,
94
+ vision, or mesh beside the systemd runtime.
95
+ - Do not run audio diagnostics during a live conversation.
96
+ - A direct ALSA test does not validate production if production uses another
97
+ API, index space, sample format, or opens the device twice.
98
+ - After a hub reboot, verify `47913`, `8080`, and `7880` before robot media.
99
+ - `tts_subscribed` alone is insufficient; require `playback_started`.
100
+ - Greeting `playback_started` alone does not prove conversation. A complete
101
+ turn also requires a user/STT event, an LLM response, and response playback.
102
+ - Container-local changes are temporary. Persist voice changes in the
103
+ ROBOVOICE checkout/image or use `robopark agent-up --path <checkout>`.
104
+
105
+ ## Minimal Verification
106
+
107
+ Run from the hub:
108
+
109
+ ```powershell
110
+ try { Invoke-RestMethod -Method Post http://127.0.0.1:8080/api/robots/bmw/pipeline-test/stop | Out-Null } catch {}
111
+ Start-Sleep 3
112
+ Invoke-RestMethod -Method Post http://127.0.0.1:8080/api/robots/bmw/pipeline-test/start | Out-Null
113
+ Start-Sleep 30
114
+ $s = Invoke-RestMethod http://127.0.0.1:8080/api/robots/bmw/pipeline-status
115
+ $id = $s.latest_session.id
116
+ $s.events | Where-Object session_id -eq $id | Select-Object timestamp,stage,status,message
117
+ ```
118
+
119
+ Required result: `playback_started ok`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.86",
3
+ "version": "2.8.88",
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",
@@ -29,8 +29,9 @@
29
29
  "static",
30
30
  "packages/robopark/scheduler",
31
31
  "packages/robopark/pi-client",
32
- "packages/robopark/vision",
33
- ".opencode/plugins",
32
+ "packages/robopark/vision",
33
+ "docs/ROBOPARK_PRODUCTION_MEMORY.md",
34
+ ".opencode/plugins",
34
35
  ".opencode/themes",
35
36
  ".opencode/tui.json",
36
37
  "README.md"
@@ -1,4 +1,7 @@
1
- # robopark
1
+ # robopark
2
+
3
+ Production architecture, device locks, verified stages, and incident memory
4
+ are recorded in [`../../docs/ROBOPARK_PRODUCTION_MEMORY.md`](../../docs/ROBOPARK_PRODUCTION_MEMORY.md).
2
5
 
3
6
  The **RoboPark fleet control CLI** — set up, watch, and drive a room full of
4
7
  talking robots from one command. `robopark` is the operator front-end; it rides
@@ -1134,6 +1134,7 @@ class LiveKitPublisher:
1134
1134
  self._tasks: list[asyncio.Task] = []
1135
1135
  self._capture: Optional["VideoCapture"] = None
1136
1136
  self._mic_capture: Optional["AudioCapture"] = None
1137
+ self._mic_streaming = asyncio.Event()
1137
1138
  # Motion sampling state belongs to the publisher instance. Keeping it
1138
1139
  # initialized here prevents shutdown/reopen paths from raising while
1139
1140
  # the camera is being handed between preview and voice sessions.
@@ -1212,13 +1213,12 @@ class LiveKitPublisher:
1212
1213
  self.video_source.capture_frame(first_frame)
1213
1214
  await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1214
1215
 
1215
- if audio_enabled:
1216
- self.audio_source = self.rtc.AudioSource(48000, 1)
1217
- self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1218
- aopts = self.rtc.TrackPublishOptions()
1219
- aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1216
+ if audio_enabled:
1217
+ self.audio_source = self.rtc.AudioSource(48000, 1)
1218
+ self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1219
+ aopts = self.rtc.TrackPublishOptions()
1220
+ aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1220
1221
  await self.room.local_participant.publish_track(self.audio_track, aopts)
1221
- await self.agent._report_pipeline("microphone_published", "ok", "Microphone track published")
1222
1222
 
1223
1223
  # Register speaker playback (subscribe to the voice agent's TTS audio
1224
1224
  # track) BEFORE opening the camera. Camera open is a slow/occasionally
@@ -1227,8 +1227,18 @@ class LiveKitPublisher:
1227
1227
  # entire event loop — including receiving the agent's greeting audio
1228
1228
  # — until it finished, so a short "Hello friend!" greeting could be
1229
1229
  # over and gone before we ever got a chance to subscribe to it.
1230
- if has_audio() and audio_enabled:
1231
- self._tasks.append(asyncio.create_task(self._audio_loop()))
1230
+ if audio_enabled:
1231
+ self._tasks.append(asyncio.create_task(self._audio_loop()))
1232
+ try:
1233
+ await asyncio.wait_for(self._mic_streaming.wait(), timeout=5.0)
1234
+ await self.agent._report_pipeline(
1235
+ "microphone_published", "ok", "Microphone track published with live PCM frames"
1236
+ )
1237
+ except asyncio.TimeoutError:
1238
+ await self.agent._report_pipeline(
1239
+ "microphone_published", "blocked", "Microphone track published but PCM capture did not start"
1240
+ )
1241
+ raise RuntimeError(f"microphone capture did not start for {self.agent.audio_capture_device}")
1232
1242
 
1233
1243
  if self._capture and self.video_source:
1234
1244
  self._tasks.append(asyncio.create_task(self._video_loop()))
@@ -1557,7 +1567,8 @@ class LiveKitPublisher:
1557
1567
  samples_per_frame = int(48000 * frame_ms / 1000)
1558
1568
  samples_per_batch = int(48000 * BATCH_MS / 1000)
1559
1569
  bytes_per_frame = samples_per_frame * 2 # int16 mono
1560
- import audioop
1570
+ import audioop
1571
+ mic_gain = max(1.0, min(float(os.getenv("ROBOPARK_MIC_GAIN", "4.0")), 12.0))
1561
1572
  _diag_peak = 0
1562
1573
  _diag_count = 0
1563
1574
  _diag_last_log = time.monotonic()
@@ -1573,11 +1584,14 @@ class LiveKitPublisher:
1573
1584
  data = bytes(batch.data)
1574
1585
  _pub_start = time.monotonic()
1575
1586
  for off in range(0, len(data) - bytes_per_frame + 1, bytes_per_frame):
1576
- chunk = data[off:off + bytes_per_frame]
1577
- frame = self.rtc.AudioFrame(
1587
+ chunk = data[off:off + bytes_per_frame]
1588
+ if mic_gain != 1.0:
1589
+ chunk = audioop.mul(chunk, 2, mic_gain)
1590
+ frame = self.rtc.AudioFrame(
1578
1591
  data=chunk, sample_rate=48000, num_channels=1, samples_per_channel=samples_per_frame,
1579
1592
  )
1580
- await self.audio_source.capture_frame(frame)
1593
+ await self.audio_source.capture_frame(frame)
1594
+ self._mic_streaming.set()
1581
1595
  p = audioop.max(chunk, 2)
1582
1596
  _diag_peak = max(_diag_peak, p)
1583
1597
  _diag_count += 1
@@ -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 generate_frames():
228
- global latest_detections, motion_detection_active, motion_detected_state
229
- global last_motion_time, motion_frame_buffer
230
-
231
- prev_gray = None
232
-
233
- while True:
234
- cam = get_camera()
235
- if cam is None or not cam.isOpened():
236
- time.sleep(1)
237
- continue
238
-
239
- success, frame = cam.read()
240
- if not success:
241
- time.sleep(0.1)
242
- continue
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
- frame_bytes = buffer.tobytes()
293
-
294
- yield (b'--frame\r\n'
295
- b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
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
- if camera:
383
- camera.release()
384
-
385
- current_camera_index = new_index
386
- camera = None
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