infinicode 2.8.92 → 2.8.93

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.
@@ -6888,7 +6888,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6888
6888
  var overlay=$('pk-camera-ops-overlay');if(overlay)overlay.innerHTML='<div class="metric"><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>readiness</span></div><div class="metric"><b>'+esc(active?'in session':'standing by')+'</b><span>conversation</span></div><div class="metric"><b>'+(row.avg_latency_ms!=null?Math.round(row.avg_latency_ms)+'ms':'--')+'</b><span>average latency</span></div><div class="metric"><b>'+(row.drop_rate!=null?Math.round(row.drop_rate*100)+'%':'--')+'</b><span>session drops</span></div>';
6889
6889
  var insights=$('pk-camera-ops-insights');if(insights)insights.innerHTML='<span>Camera relay</span><b class="'+(cameraLive?'ok':'bad')+'">'+(cameraLive?'LIVE':'CONNECTING')+'</b><span>Mesh node</span><b class="'+(n.connected?'ok':'bad')+'">'+(n.connected?'connected':'offline')+'</b><span>Production mode</span><b class="'+(device.production_mode?'ok':'')+'">'+(device.production_mode?'armed':'disabled')+'</b><span>Readiness</span><b>'+esc(readiness.replace(/_/g,' '))+'</b><span>Heartbeat</span><b>'+(row.heartbeat_age_seconds!=null?Math.round(row.heartbeat_age_seconds)+'s ago':'--')+'</b><span>Current room</span><b>'+esc(String(row.current_session_id||row.active_session_id||'none').slice(0,24))+'</b><span>Triggers</span><b>'+esc(row.trigger_count!=null?row.trigger_count:0)+'</b><span>Sessions</span><b>'+esc(row.sessions_total!=null?row.sessions_total:0)+'</b>';
6890
6890
  var stages=[['Robot online',!!n.connected],['Camera streaming',cameraLive],['Motion detector armed',!!device.production_mode],['Scheduler session',active],['Voice pipeline',readiness==='in_session'||active]],pipe=$('pk-camera-ops-pipeline');if(pipe)pipe.innerHTML=stages.map(function(s){return '<div><i class="'+(s[1]?'ok':'warn')+'"></i><span>'+esc(s[0])+'</span><b>'+(s[1]?'ready':'waiting')+'</b></div>';}).join('');
6891
- var inv=device.device_inventory||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
6891
+ var inv=device.device_inventory||{},mh=inv.media_health||{},hardware=$('pk-camera-ops-hardware');if(hardware)hardware.innerHTML='<span>CPU</span><b>'+(hw.cpu&&hw.cpu.percent!=null?Math.round(hw.cpu.percent)+'%':'--')+'</b><span>Memory</span><b>'+(hw.memory&&hw.memory.percent!=null?Math.round(hw.memory.percent)+'%':'--')+'</b><span>Temperature</span><b>'+(hw.temperatureC!=null?Math.round(hw.temperatureC)+'C':'--')+'</b><span>Camera inputs</span><b>'+((inv.video||[]).length||0)+'</b><span>Microphones</span><b>'+((inv.audio_input||[]).length||0)+'</b><span>Speaker outputs</span><b>'+((inv.audio_output||[]).length||0)+'</b><span>Camera permission</span><b class="'+(mh.camera_access===false?'bad':'ok')+'">'+(mh.camera_access===false?'DENIED':mh.camera_access===true?'granted':'--')+'</b><span>Audio permission</span><b class="'+(mh.audio_access===false?'bad':'ok')+'">'+(mh.audio_access===false?'DENIED':mh.audio_access===true?'granted':'--')+'</b><span>Camera worker</span><b class="'+(mh.camera_stalled?'bad':'ok')+'">'+(mh.camera_stalled?'STALLED':mh.camera_worker?'streaming':'--')+'</b><span>Frame age</span><b>'+(mh.camera_frame_age_seconds!=null?Number(mh.camera_frame_age_seconds).toFixed(1)+'s':'--')+'</b><span>Selected camera</span><b>'+esc(device.video_device||'auto')+'</b><span>Selected microphone</span><b>'+esc(device.audio_device||'default')+'</b>';
6892
6892
  var prod=$('pk-camera-ops-production');if(prod){prod.textContent=device.production_mode?'Disable production':'Enable production';prod.classList.toggle('primary',!!device.production_mode);}
6893
6893
  }
6894
6894
  function reloadCameraOperations(){
@@ -65,19 +65,56 @@ async function registerSystemd(service) {
65
65
  const envLines = Object.entries(service.env ?? {})
66
66
  .map(([k, v]) => `Environment="${k}=${v.replace(/"/g, '\\"')}"`)
67
67
  .join('\n');
68
- const unit = `[Unit]
69
- Description=RoboPark ${service.role} for ${service.name}
70
- After=network-online.target
71
- Wants=network-online.target
72
-
73
- [Service]
74
- Type=simple
75
- ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
68
+ const mediaService = service.role === 'robot-runtime' || service.role === 'robot-preview-agent' || service.role === 'robot-vision-agent';
69
+ const mediaGroups = mediaService
70
+ ? ['audio', 'video', 'render', 'input', 'plugdev'].filter(group => spawnSync('getent', ['group', group], { stdio: 'ignore' }).status === 0)
71
+ : [];
72
+ const mediaPolicy = mediaService ? [
73
+ mediaGroups.length ? `SupplementaryGroups=${mediaGroups.join(' ')}` : '',
74
+ 'UMask=0007',
75
+ 'LimitNOFILE=65536',
76
+ 'TasksMax=512',
77
+ 'OOMScoreAdjust=-500',
78
+ ].filter(Boolean).join('\n') : '';
79
+ let mediaRulesInstalled = false;
80
+ if (mediaService) {
81
+ const rulesPath = '/etc/udev/rules.d/70-robopark-media.rules';
82
+ const rules = [
83
+ '# Managed by RoboPark. Shared hardware profile for production robots.',
84
+ 'SUBSYSTEM=="video4linux", GROUP="video", MODE="0660"',
85
+ 'SUBSYSTEM=="video4linux", ENV{ID_BUS}=="usb", ATTR{index}=="0", SYMLINK+="robopark-camera"',
86
+ 'SUBSYSTEM=="sound", GROUP="audio", MODE="0660"',
87
+ '',
88
+ ].join('\n');
89
+ try {
90
+ writeFileSync(rulesPath, rules, 'utf8');
91
+ spawnSync('udevadm', ['control', '--reload-rules'], { stdio: 'ignore' });
92
+ spawnSync('udevadm', ['trigger', '--subsystem-match=video4linux'], { stdio: 'ignore' });
93
+ spawnSync('udevadm', ['trigger', '--subsystem-match=sound'], { stdio: 'ignore' });
94
+ mediaRulesInstalled = true;
95
+ }
96
+ catch {
97
+ // The explicit systemd groups below still cover standard distro rules.
98
+ }
99
+ }
100
+ const unit = `[Unit]
101
+ Description=RoboPark ${service.role} for ${service.name}
102
+ After=network-online.target sound.target
103
+ Wants=network-online.target sound.target
104
+ StartLimitIntervalSec=120
105
+ StartLimitBurst=20
106
+
107
+ [Service]
108
+ Type=simple
109
+ ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
76
110
  WorkingDirectory=${service.workingDir ?? homedir()}
77
111
  Restart=always
78
- RestartSec=5
112
+ RestartSec=3
79
113
  KillMode=control-group
80
- TimeoutStopSec=10
114
+ TimeoutStopSec=20
115
+ SendSIGKILL=yes
116
+ FinalKillSignal=SIGKILL
117
+ ${mediaPolicy}
81
118
  ${envLines}
82
119
 
83
120
  [Install]
@@ -93,7 +130,10 @@ WantedBy=multi-user.target
93
130
  if (enabled.status !== 0) {
94
131
  return { ok: false, message: `wrote ${unitPath}, but systemctl enable --now failed: ${enabled.stderr || enabled.stdout}` };
95
132
  }
96
- return { ok: true, message: `enabled and started systemd unit ${unitName}` };
133
+ return {
134
+ ok: true,
135
+ message: `enabled and started systemd unit ${unitName}${mediaRulesInstalled ? ' with persistent media permissions' : ''}`,
136
+ };
97
137
  }
98
138
  catch (e) {
99
139
  return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
@@ -15,7 +15,9 @@ import { fileURLToPath } from 'node:url';
15
15
  import chalk from 'chalk';
16
16
  import { resolveInfinicodeBin } from './serve.js';
17
17
  const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
18
+ const VISION_STATUS_URL = 'http://127.0.0.1:5000/api/camera/status';
18
19
  const RESTART_DELAY_MS = 5_000;
20
+ const MEDIA_WATCHDOG_INTERVAL_MS = 10_000;
19
21
  function resetStaleEnrollment() {
20
22
  const configDir = join(homedir(), '.robopark');
21
23
  const tokenPath = join(configDir, 'device_token');
@@ -97,6 +99,9 @@ export async function roboparkRobotRuntime(opts) {
97
99
  command: process.execPath,
98
100
  args: [cli, 'vision-agent', '--foreground', '--port', '5000',
99
101
  '--motion-webhook-url', 'http://127.0.0.1:5057/'],
102
+ env: {
103
+ ROBOPARK_CAMERA_DEVICE: opts.videoDevice ?? '/dev/robopark-camera',
104
+ },
100
105
  });
101
106
  }
102
107
  const previewArgs = [cli, 'preview-agent', '--foreground',
@@ -122,6 +127,10 @@ export async function roboparkRobotRuntime(opts) {
122
127
  ? 'http://127.0.0.1:5000/api/media/inventory'
123
128
  : '',
124
129
  ROBOVISION_CAMERA: opts.vision !== false ? '1' : '0',
130
+ // Fleet hardware profile: resolve volatile ALSA hw:X,Y coordinates
131
+ // from these USB product labels on every inventory refresh.
132
+ ROBOPARK_AUDIO_INPUT_MATCH: 'Usb Audio Device: USB Audio',
133
+ ROBOPARK_AUDIO_OUTPUT_MATCH: 'USB Audio Device: -',
125
134
  // Inventory changes should reach the Control Center promptly.
126
135
  HEARTBEAT_INTERVAL: '5',
127
136
  },
@@ -182,6 +191,43 @@ export async function roboparkRobotRuntime(opts) {
182
191
  signalChild(entry, 'SIGTERM');
183
192
  });
184
193
  };
194
+ let visionFailureCount = 0;
195
+ let visionRecoveryInFlight = false;
196
+ const monitorVision = async () => {
197
+ if (stopping || recycling || visionRecoveryInFlight || opts.vision === false)
198
+ return;
199
+ const vision = children.find(entry => entry.label === 'vision');
200
+ if (!vision?.child)
201
+ return;
202
+ let healthy = false;
203
+ try {
204
+ const response = await fetch(VISION_STATUS_URL, { signal: AbortSignal.timeout(2_000) });
205
+ if (response.ok) {
206
+ const status = await response.json();
207
+ const frameTooOld = status.worker_started === true
208
+ && typeof status.last_frame_age_seconds === 'number'
209
+ && status.last_frame_age_seconds > 15;
210
+ healthy = !status.read_stalled && !frameTooOld;
211
+ }
212
+ }
213
+ catch {
214
+ healthy = false;
215
+ }
216
+ visionFailureCount = healthy ? 0 : visionFailureCount + 1;
217
+ if (visionFailureCount < 3)
218
+ return;
219
+ visionRecoveryInFlight = true;
220
+ console.error(chalk.yellow(' media watchdog: RoboVision unhealthy for 30s; recycling camera/audio pair'));
221
+ try {
222
+ await stopChild(vision);
223
+ if (!stopping)
224
+ start(vision);
225
+ visionFailureCount = 0;
226
+ }
227
+ finally {
228
+ visionRecoveryInFlight = false;
229
+ }
230
+ };
185
231
  const recycleAfterMeshUpdate = async () => {
186
232
  if (stopping || recycling)
187
233
  return;
@@ -231,8 +277,10 @@ export async function roboparkRobotRuntime(opts) {
231
277
  : chalk.yellow(' RoboVision is degraded; preview heartbeat remains online and will retry'));
232
278
  });
233
279
  }
280
+ const mediaWatchdog = setInterval(() => { void monitorVision(); }, MEDIA_WATCHDOG_INTERVAL_MS);
234
281
  const shutdown = () => {
235
282
  stopping = true;
283
+ clearInterval(mediaWatchdog);
236
284
  for (const entry of [...children, preview])
237
285
  signalChild(entry, 'SIGTERM');
238
286
  };
@@ -196,6 +196,9 @@ async function setupRobot(config, opts, ctx, internal) {
196
196
  // production trigger — on by default. --no-vision opts out (e.g. no camera
197
197
  // on this box, or RoboVisionAI_PI isn't installed).
198
198
  const visionEnabled = opts.vision !== false;
199
+ const defaultVideoDevice = process.platform === 'linux' && opts.autoStart
200
+ ? '/dev/robopark-camera'
201
+ : '/dev/video0';
199
202
  const runtimeArgs = [
200
203
  'robot', 'up',
201
204
  '--name', internal.name,
@@ -204,7 +207,7 @@ async function setupRobot(config, opts, ctx, internal) {
204
207
  '--token', internal.token,
205
208
  '--port', String(internal.port),
206
209
  networkFlag,
207
- '--video-device', opts.videoDevice ?? '/dev/video0',
210
+ '--video-device', opts.videoDevice ?? defaultVideoDevice,
208
211
  '--audio-device', opts.audioDevice ?? 'Usb Audio Device: USB Audio (hw:3,0)',
209
212
  ];
210
213
  if (opts.enrollmentToken)
@@ -49,13 +49,34 @@ export async function roboparkVisionAgent(opts) {
49
49
  console.log(` audio: ${chalk.green('RoboVision audio server :8000')}`);
50
50
  console.log();
51
51
  if (opts.foreground) {
52
- if (audioScript) {
53
- const audio = spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } });
54
- audio.unref();
55
- }
56
- const proc = spawn(python, args, { stdio: 'inherit' });
52
+ const audio = audioScript
53
+ ? spawn(python, [audioScript], { stdio: 'inherit', env: { ...process.env, ROBOPARK_AUDIO_SERVER: '1' } })
54
+ : null;
55
+ const camera = spawn(python, args, { stdio: 'inherit' });
56
+ let stopping = false;
57
+ const stopChildren = () => {
58
+ if (stopping)
59
+ return;
60
+ stopping = true;
61
+ camera.kill('SIGTERM');
62
+ audio?.kill('SIGTERM');
63
+ };
64
+ process.once('SIGINT', stopChildren);
65
+ process.once('SIGTERM', stopChildren);
66
+ // If either required media service exits, tear down its sibling. The
67
+ // robot runtime then restarts one coherent pair instead of accumulating
68
+ // orphan audio servers and duplicate owners of ports/devices.
57
69
  await new Promise((resolve) => {
58
- proc.on('close', () => resolve());
70
+ let resolved = false;
71
+ const finish = () => {
72
+ if (resolved)
73
+ return;
74
+ resolved = true;
75
+ stopChildren();
76
+ resolve();
77
+ };
78
+ camera.once('close', finish);
79
+ audio?.once('close', finish);
59
80
  });
60
81
  return;
61
82
  }
@@ -127,3 +127,18 @@ $s.events | Where-Object session_id -eq $id | Select-Object timestamp,stage,stat
127
127
  ```
128
128
 
129
129
  Required result: `playback_started ok`.
130
+
131
+ ## Fleet Media Reliability Contract (2.8.93+)
132
+
133
+ - Linux setup installs persistent udev permissions for `video4linux` and
134
+ `sound`, plus `/dev/robopark-camera` for the primary USB capture interface.
135
+ - The systemd runtime receives available media groups and uses control-group
136
+ shutdown so descendants cannot survive an update or restart.
137
+ - `/run/lock/robopark-speaker.lock` and
138
+ `/run/lock/robopark-microphone.lock` serialize production media and tests.
139
+ - The vision launcher owns both camera and audio children. If either exits,
140
+ the runtime restarts one coherent pair instead of leaving an orphan owner.
141
+ - RoboVision opens its shared camera worker at boot. The runtime polls camera
142
+ status and recycles media after three consecutive unhealthy checks.
143
+ - Park media operations display camera/audio permission, camera worker state,
144
+ and latest frame age from `device_inventory.media_health`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.92",
3
+ "version": "2.8.93",
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",
@@ -0,0 +1,57 @@
1
+ """Cross-process media ownership for Linux robot appliances."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ class MediaLock:
12
+ def __init__(self, kind: str, timeout: float = 5.0):
13
+ self.kind = kind
14
+ self.timeout = timeout
15
+ self.fd: Optional[int] = None
16
+
17
+ def acquire(self) -> "MediaLock":
18
+ if not sys.platform.startswith("linux"):
19
+ return self
20
+ import fcntl
21
+
22
+ lock_dir = Path("/run/lock") if os.access("/run/lock", os.W_OK) else Path("/tmp")
23
+ path = lock_dir / f"robopark-{self.kind}.lock"
24
+ self.fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o660)
25
+ deadline = time.monotonic() + self.timeout
26
+ while True:
27
+ try:
28
+ fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
29
+ os.ftruncate(self.fd, 0)
30
+ os.write(self.fd, f"{os.getpid()}\n".encode("ascii"))
31
+ return self
32
+ except BlockingIOError:
33
+ if time.monotonic() >= deadline:
34
+ self.release()
35
+ raise TimeoutError(f"{self.kind} is owned by another RoboPark media process")
36
+ time.sleep(0.05)
37
+
38
+ def release(self) -> None:
39
+ if self.fd is None:
40
+ return
41
+ try:
42
+ if sys.platform.startswith("linux"):
43
+ import fcntl
44
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
45
+ finally:
46
+ os.close(self.fd)
47
+ self.fd = None
48
+
49
+ def __enter__(self) -> "MediaLock":
50
+ return self.acquire()
51
+
52
+ def __exit__(self, exc_type, exc, traceback) -> None:
53
+ self.release()
54
+
55
+
56
+ def media_lock(kind: str, timeout: float = 5.0) -> MediaLock:
57
+ return MediaLock(kind, timeout)
@@ -70,6 +70,31 @@ _DEVICE_INVENTORY_CACHE: Optional[dict] = None
70
70
  _DEVICE_INVENTORY_CACHE_AT = 0.0
71
71
  DEVICE_INVENTORY_CACHE_SECONDS = 5.0
72
72
  ROBOVISION_MEDIA_URL = os.getenv("ROBOVISION_MEDIA_URL", "http://127.0.0.1:5000/api/media/inventory")
73
+
74
+
75
+ def _stable_audio_label(value: object) -> str:
76
+ """Compare USB product names without volatile ALSA card coordinates."""
77
+ import re
78
+ return " ".join(re.sub(r"\s*\(hw:\d+,\d+\)\s*$", "", str(value), flags=re.I).lower().split())
79
+
80
+
81
+ def _resolve_inventory_audio(items: list, selected: object, preferred: str) -> str:
82
+ if preferred:
83
+ wanted = _stable_audio_label(preferred)
84
+ match = next((item for item in items if _stable_audio_label(item.get("name")) == wanted), None)
85
+ if match and match.get("name"):
86
+ return str(match["name"])
87
+ selected_text = str(selected)
88
+ selected_label = _stable_audio_label(selected_text)
89
+ match = next(
90
+ (
91
+ item for item in items
92
+ if str(item.get("id")) == selected_text
93
+ or _stable_audio_label(item.get("name")) == selected_label
94
+ ),
95
+ None,
96
+ )
97
+ return str(match.get("name")) if match and match.get("name") else selected_text
73
98
 
74
99
 
75
100
  def _normalize_livekit_url(url: Optional[str]) -> Optional[str]:
@@ -103,17 +128,23 @@ def _play_audio_effect(selected_output: str | None, effect: str) -> None:
103
128
  if sys.platform.startswith("linux") and "hw:" in selected:
104
129
  import re
105
130
  import subprocess
131
+ from media_lock import media_lock
106
132
 
107
133
  match = re.search(r"\b(hw:\d+,\d+)\b", selected)
108
134
  if not match:
109
135
  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
- )
136
+ try:
137
+ with media_lock("speaker", timeout=3.0):
138
+ result = subprocess.run(
139
+ [
140
+ "aplay", "-q", "-D", f"plug{match.group(1)}", "-t", "raw",
141
+ "-f", "S16_LE", "-r", str(sample_rate), "-c", str(channels),
142
+ ],
143
+ input=bytes(frames), capture_output=True, timeout=3.0,
144
+ )
145
+ except TimeoutError as exc:
146
+ logger.warning("audio effect skipped: %s", exc)
147
+ return
117
148
  if result.returncode:
118
149
  logger.warning(
119
150
  "audio effect failed on %s: %s",
@@ -225,7 +256,38 @@ def _get_device_inventory() -> dict:
225
256
  pa.terminate()
226
257
  except Exception as e:
227
258
  logger.debug(f"audio inventory unavailable: {e}")
228
-
259
+
260
+ media_health = {
261
+ "service_uid": os.geteuid() if hasattr(os, "geteuid") else None,
262
+ "camera_access": None,
263
+ "audio_access": None,
264
+ "camera_worker": None,
265
+ "camera_stalled": None,
266
+ "camera_frame_age_seconds": None,
267
+ }
268
+ if sys.platform.startswith("linux"):
269
+ camera_nodes = [
270
+ str(item.get("id")) for item in inventory.get("video", [])
271
+ if str(item.get("id", "")).startswith("/dev/video")
272
+ ]
273
+ sound_nodes = glob.glob("/dev/snd/pcm*")
274
+ media_health["camera_access"] = bool(camera_nodes) and all(
275
+ os.access(path, os.R_OK | os.W_OK) for path in camera_nodes[:1]
276
+ )
277
+ media_health["audio_access"] = bool(sound_nodes) and all(
278
+ os.access(path, os.R_OK | os.W_OK) for path in sound_nodes
279
+ )
280
+ try:
281
+ camera_response = httpx.get("http://127.0.0.1:5000/api/camera/status", timeout=0.6)
282
+ if camera_response.is_success:
283
+ camera_status = camera_response.json()
284
+ media_health["camera_worker"] = bool(camera_status.get("worker_started"))
285
+ media_health["camera_stalled"] = bool(camera_status.get("read_stalled"))
286
+ media_health["camera_frame_age_seconds"] = camera_status.get("last_frame_age_seconds")
287
+ except Exception:
288
+ pass
289
+ inventory["media_health"] = media_health
290
+
229
291
  _DEVICE_INVENTORY_CACHE = inventory
230
292
  _DEVICE_INVENTORY_CACHE_AT = now
231
293
  logger.info(
@@ -707,26 +769,18 @@ class PreviewAgent:
707
769
  self.audio_device = data["audio_device"]
708
770
  inventory = data.get("device_inventory") or {}
709
771
  selected = str(self.audio_device)
710
- self.audio_capture_device = next(
711
- (
712
- str(item.get("name"))
713
- for item in inventory.get("audio_input", [])
714
- if str(item.get("id")) == selected and item.get("name")
715
- ),
716
- self.audio_device,
772
+ self.audio_capture_device = _resolve_inventory_audio(
773
+ inventory.get("audio_input", []), selected,
774
+ os.getenv("ROBOPARK_AUDIO_INPUT_MATCH", ""),
717
775
  )
718
776
  if data.get("audio_output_device") is not None:
719
777
  changed = changed or self.audio_output_device != data["audio_output_device"]
720
778
  self.audio_output_device = data["audio_output_device"]
721
779
  inventory = data.get("device_inventory") or {}
722
780
  selected = str(self.audio_output_device)
723
- self.audio_playback_device = next(
724
- (
725
- str(item.get("name"))
726
- for item in inventory.get("audio_output", [])
727
- if str(item.get("id")) == selected and item.get("name")
728
- ),
729
- self.audio_output_device,
781
+ self.audio_playback_device = _resolve_inventory_audio(
782
+ inventory.get("audio_output", []), selected,
783
+ os.getenv("ROBOPARK_AUDIO_OUTPUT_MATCH", ""),
730
784
  )
731
785
  if changed:
732
786
  await self._sync_robovision_media_config()
@@ -1275,6 +1329,7 @@ class LiveKitPublisher:
1275
1329
  selected_output = str(self.agent.audio_playback_device or "default")
1276
1330
  pa = None
1277
1331
  output_device_index = None
1332
+ speaker_guard = None
1278
1333
 
1279
1334
  # RoboVision inventories Linux devices through sounddevice/PortAudio,
1280
1335
  # but the numeric indices are not stable across PyAudio builds. More
@@ -1284,23 +1339,33 @@ class LiveKitPublisher:
1284
1339
  if sys.platform.startswith("linux") and "hw:" in selected_output:
1285
1340
  import re
1286
1341
  import subprocess
1342
+ from media_lock import media_lock
1287
1343
 
1288
1344
  match = re.search(r"\b(hw:\d+,\d+)\b", selected_output)
1289
1345
  if not match:
1290
1346
  logger.warning(f"audio out: no ALSA hardware address in {selected_output!r}")
1291
1347
  return
1292
1348
  alsa_device = f"plug{match.group(1)}"
1293
- process = subprocess.Popen(
1294
- [
1295
- "aplay", "-q", "-D", alsa_device, "-t", "raw",
1296
- "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1297
- ],
1298
- stdin=subprocess.PIPE,
1299
- stderr=subprocess.PIPE,
1300
- )
1349
+ try:
1350
+ speaker_guard = media_lock("speaker", timeout=8.0).acquire()
1351
+ process = subprocess.Popen(
1352
+ [
1353
+ "aplay", "-q", "-D", alsa_device, "-t", "raw",
1354
+ "-f", "S16_LE", "-r", str(OUT_RATE), "-c", str(OUT_CHANNELS),
1355
+ ],
1356
+ stdin=subprocess.PIPE,
1357
+ stderr=subprocess.PIPE,
1358
+ )
1359
+ except Exception as exc:
1360
+ if speaker_guard is not None:
1361
+ speaker_guard.release()
1362
+ logger.warning(f"audio out: could not acquire {alsa_device}: {exc}")
1363
+ return
1301
1364
  if process.stdin is None:
1302
1365
  logger.warning(f"audio out: aplay did not expose stdin for {alsa_device}")
1303
1366
  process.kill()
1367
+ process.wait(timeout=1)
1368
+ speaker_guard.release()
1304
1369
  return
1305
1370
 
1306
1371
  class _AplayOutput:
@@ -1522,6 +1587,8 @@ class LiveKitPublisher:
1522
1587
  out.close()
1523
1588
  if pa is not None:
1524
1589
  pa.terminate()
1590
+ if speaker_guard is not None:
1591
+ speaker_guard.release()
1525
1592
 
1526
1593
  async def stop(self) -> None:
1527
1594
  self._stop_event.set()
@@ -1864,21 +1931,30 @@ class AlsaAudioCapture(AudioCapture):
1864
1931
  def __init__(self, device: str):
1865
1932
  import re
1866
1933
  import subprocess
1934
+ from media_lock import media_lock
1867
1935
 
1868
1936
  match = re.search(r"\b(hw:\d+,\d+)\b", device)
1869
1937
  if not match:
1870
1938
  raise ValueError(f"no ALSA hardware address in {device!r}")
1871
1939
  alsa_device = f"plug{match.group(1)}"
1872
1940
  self.buffer = bytearray()
1873
- self.process = subprocess.Popen(
1874
- [
1875
- "arecord", "-q", "-D", alsa_device, "-t", "raw",
1876
- "-f", "S16_LE", "-r", "48000", "-c", "1",
1877
- ],
1878
- stdout=subprocess.PIPE,
1879
- stderr=subprocess.PIPE,
1880
- )
1941
+ self.media_guard = media_lock("microphone", timeout=5.0).acquire()
1942
+ try:
1943
+ self.process = subprocess.Popen(
1944
+ [
1945
+ "arecord", "-q", "-D", alsa_device, "-t", "raw",
1946
+ "-f", "S16_LE", "-r", "48000", "-c", "1",
1947
+ ],
1948
+ stdout=subprocess.PIPE,
1949
+ stderr=subprocess.PIPE,
1950
+ )
1951
+ except Exception:
1952
+ self.media_guard.release()
1953
+ raise
1881
1954
  if self.process.stdout is None:
1955
+ self.process.kill()
1956
+ self.process.wait(timeout=1)
1957
+ self.media_guard.release()
1882
1958
  raise RuntimeError("arecord did not provide a PCM stream")
1883
1959
  self.alsa_device = alsa_device
1884
1960
 
@@ -1904,13 +1980,16 @@ class AlsaAudioCapture(AudioCapture):
1904
1980
  )
1905
1981
 
1906
1982
  def stop(self):
1907
- if self.process.poll() is None:
1908
- self.process.terminate()
1909
- try:
1910
- self.process.wait(timeout=2)
1911
- except Exception:
1912
- self.process.kill()
1913
- self.process.wait(timeout=1)
1983
+ try:
1984
+ if self.process.poll() is None:
1985
+ self.process.terminate()
1986
+ try:
1987
+ self.process.wait(timeout=2)
1988
+ except Exception:
1989
+ self.process.kill()
1990
+ self.process.wait(timeout=1)
1991
+ finally:
1992
+ self.media_guard.release()
1914
1993
 
1915
1994
 
1916
1995
  class PyAudioCapture(AudioCapture):
@@ -2000,6 +2079,7 @@ def create_audio_capture(device: str) -> Optional[AudioCapture]:
2000
2079
  return AlsaAudioCapture(device)
2001
2080
  except Exception as e:
2002
2081
  logger.warning(f"ALSA capture unavailable for {device}: {e}")
2082
+ raise
2003
2083
  try:
2004
2084
  import pyaudio # noqa: F401
2005
2085
  return PyAudioCapture(device if device != "default" else None)
@@ -600,24 +600,29 @@ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
600
600
  # ALSA can retain an exclusive USB handle briefly after the LiveKit
601
601
  # playback stream closes. Retry for a bounded period instead of declaring
602
602
  # a valid device unsupported on the first EBUSY response.
603
- for attempt in range(5):
604
- if attempt:
605
- time.sleep(0.5)
606
- try:
607
- completed = subprocess.run(
608
- command,
609
- input=raw,
610
- stdout=subprocess.DEVNULL,
611
- stderr=subprocess.PIPE,
612
- timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
613
- check=False,
614
- )
615
- except Exception as exc:
616
- last_error = f"{type(exc).__name__}: {exc}"
617
- continue
618
- if completed.returncode == 0:
619
- return True, alsa_device
620
- last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
603
+ try:
604
+ from media_lock import media_lock
605
+ with media_lock("speaker", timeout=8.0):
606
+ for attempt in range(5):
607
+ if attempt:
608
+ time.sleep(0.5)
609
+ try:
610
+ completed = subprocess.run(
611
+ command,
612
+ input=raw,
613
+ stdout=subprocess.DEVNULL,
614
+ stderr=subprocess.PIPE,
615
+ timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
616
+ check=False,
617
+ )
618
+ except Exception as exc:
619
+ last_error = f"{type(exc).__name__}: {exc}"
620
+ continue
621
+ if completed.returncode == 0:
622
+ return True, alsa_device
623
+ last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
624
+ except TimeoutError as exc:
625
+ return False, str(exc)
621
626
  return False, f"{alsa_device}: {last_error}"
622
627
 
623
628