infinicode 2.8.82 → 2.8.84

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.
@@ -4005,7 +4005,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4005
4005
  var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;
4006
4006
  var previewOn=false, previewTimer=null; // on-demand operator camera preview
4007
4007
  var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
4008
- var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;
4008
+ var lkRoom=null, micAnalyser=null, micMonitorSource=null, micMonitorGain=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;
4009
4009
  var imgs={}; // preloaded robot concept avatars
4010
4010
  var TW=44, TH=22, PARK_W=15, PARK_D=8;
4011
4011
  // Plaza footprint and parking lot (upper-right corner of the plaza)
@@ -6096,6 +6096,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6096
6096
  if(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }
6097
6097
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
6098
6098
  lkAudioEls.forEach(function(el){try{el.pause();el.remove();}catch(e){}});lkAudioEls=[];
6099
+ if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;
6099
6100
  micMonitorAudible=false;micMonitorConnecting=false;micMonitorState='no active conversation session';
6100
6101
  micAnalyser=null;
6101
6102
  }
@@ -6735,7 +6736,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6735
6736
  });
6736
6737
  });
6737
6738
  var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});
6738
- var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));lkAudioEls.forEach(function(el){el.volume=micMonitorVolume;});});
6739
+ var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;});
6739
6740
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
6740
6741
  _bindShellSection(n);
6741
6742
  _spkTestRenderHistory(schedId(n));
@@ -6946,12 +6947,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
6946
6947
  function setMicMonitorAudible(enabled){
6947
6948
  micMonitorAudible=!!enabled;
6948
6949
  var btn=$('pk-mic-listen'),note=$('pk-mic-listen-note');
6950
+ if(micMonitorAudible){
6951
+ var AC=window.AudioContext||window.webkitAudioContext;
6952
+ if(AC) audioCtx=audioCtx||new AC();
6953
+ if(audioCtx&&audioCtx.state==='suspended') audioCtx.resume().catch(function(e){if(note)note.textContent='Browser audio resume failed: '+e.message;});
6954
+ }
6955
+ if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;
6949
6956
  if(btn){btn.textContent=micMonitorAudible?'Mute browser monitor':'Listen in browser';btn.classList.toggle('primary',micMonitorAudible);}
6950
6957
  if(note)note.textContent=micMonitorAudible?(lkAudioEls.length?'Robot mic is playing in this browser. Use headphones.':'Listening armed — waiting for the robot mic track.'):'Browser monitoring muted.';
6951
- lkAudioEls.forEach(function(el){
6952
- el.muted=!micMonitorAudible;el.volume=micMonitorVolume;
6953
- if(micMonitorAudible)el.play().catch(function(e){if(note)note.textContent='Browser blocked playback: '+e.message+' — press Listen again.';});
6954
- });
6958
+ lkAudioEls.forEach(function(el){el.muted=true;});
6955
6959
  }
6956
6960
  function startMicMonitor(n){
6957
6961
  if(lkRoom||micMonitorConnecting)return;
@@ -7000,12 +7004,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
7000
7004
  // that drowns out the actual conversation. The VU meter above
7001
7005
  // (hookMicAnalyser) taps the raw track directly and works fine
7002
7006
  // muted.
7003
- var audio=track.attach(); audio.autoplay=true; audio.playsInline=true;audio.setAttribute('playsinline','');audio.controls=false; audio.muted=!micMonitorAudible; audio.volume=micMonitorVolume;audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
7007
+ var audio=track.attach(); audio.autoplay=false; audio.playsInline=true;audio.setAttribute('playsinline','');audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
7004
7008
  var listenNote=$('pk-mic-listen-note');if(listenNote&&micMonitorAudible)listenNote.textContent='Robot mic is playing in this browser. Use headphones.';
7005
- audio.play().catch(function(e){var listenNote=$('pk-mic-listen-note');if(listenNote)listenNote.textContent='Browser blocked robot audio: '+e.message+'. Click Listen in browser.';});
7006
7009
  }
7007
7010
  });
7008
- lkRoom.on(LK.RoomEvent.Disconnected, function(){lkRoom=null;micAnalyser=null;micMonitorConnecting=false;micMonitorState='session ended';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · session ended';if(!audioOnly&&note)note.textContent='session ended';if(!audioOnly&&res)res.textContent='—';});
7011
+ lkRoom.on(LK.RoomEvent.Disconnected, function(){lkRoom=null;micAnalyser=null;if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}micMonitorSource=null;micMonitorGain=null;micMonitorConnecting=false;micMonitorState='session ended';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · session ended';if(!audioOnly&&note)note.textContent='session ended';if(!audioOnly&&res)res.textContent='—';});
7009
7012
  var livekitUrl=info.url;
7010
7013
  if((location.hostname==='localhost'||location.hostname==='127.0.0.1') && /^ws:\\/\\/localhost(?::|\\/)/i.test(livekitUrl)) livekitUrl=livekitUrl.replace(/^ws:\\/\\/localhost/i,'ws://127.0.0.1');
7011
7014
  lkRoom.connect(livekitUrl, info.token).then(function(){micMonitorState='connected, waiting for robot mic publication';var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · '+micMonitorState;if(!audioOnly&&note)note.textContent='connected · waiting for camera track…';})
@@ -7014,13 +7017,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
7014
7017
  }
7015
7018
  // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
7016
7019
  // Audio analyser and read RMS in startMeters (no invented levels).
7017
- function hookMicAnalyser(track){
7020
+ function hookMicAnalyser(track){
7018
7021
  try{
7019
7022
  var AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
7020
7023
  audioCtx=audioCtx||new AC(); if(audioCtx.state==='suspended') audioCtx.resume().catch(function(){});
7021
7024
  var ms=track.mediaStreamTrack; if(!ms) return;
7022
- var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
7023
- var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
7025
+ if(micMonitorSource)try{micMonitorSource.disconnect();}catch(e){}
7026
+ var src=audioCtx.createMediaStreamSource(new MediaStream([ms]));
7027
+ var an=audioCtx.createAnalyser(); an.fftSize=256; src.connect(an); micAnalyser=an;
7028
+ var gain=audioCtx.createGain();gain.gain.value=micMonitorAudible?micMonitorVolume:0;src.connect(gain);gain.connect(audioCtx.destination);micMonitorSource=src;micMonitorGain=gain;
7024
7029
  }catch(e){}
7025
7030
  }
7026
7031
  function previewCam(n){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.82",
3
+ "version": "2.8.84",
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",
@@ -397,6 +397,10 @@ class PreviewAgent:
397
397
  # use the resolved hardware name reported in that same inventory.
398
398
  self.audio_capture_device = self.audio_device
399
399
  self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
400
+ # Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
401
+ # number the same ALSA cards differently, so resolve the selected ID
402
+ # to its hardware name before opening any playback stream.
403
+ self.audio_playback_device = self.audio_output_device
400
404
  self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
401
405
  self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
402
406
  self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
@@ -689,6 +693,16 @@ class PreviewAgent:
689
693
  if data.get("audio_output_device") is not None:
690
694
  changed = changed or self.audio_output_device != data["audio_output_device"]
691
695
  self.audio_output_device = data["audio_output_device"]
696
+ inventory = data.get("device_inventory") or {}
697
+ selected = str(self.audio_output_device)
698
+ self.audio_playback_device = next(
699
+ (
700
+ str(item.get("name"))
701
+ for item in inventory.get("audio_output", [])
702
+ if str(item.get("id")) == selected and item.get("name")
703
+ ),
704
+ self.audio_output_device,
705
+ )
692
706
  if changed:
693
707
  await self._sync_robovision_media_config()
694
708
  except Exception as e:
@@ -812,7 +826,7 @@ class PreviewAgent:
812
826
  self._vision_last_activity = 0.0
813
827
  self._remote_session_ended = False
814
828
  await self._stop_publisher()
815
- await asyncio.to_thread(_play_audio_effect, self.audio_output_device, "disconnect")
829
+ await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
816
830
  self._current_state = PreviewState()
817
831
 
818
832
  async def _heartbeat_loop(self) -> None:
@@ -865,7 +879,7 @@ class PreviewAgent:
865
879
  restore_state = self._current_state if self._publisher else None
866
880
  if restore_state:
867
881
  await self._stop_publisher()
868
- await asyncio.sleep(0.35)
882
+ await asyncio.sleep(1.0)
869
883
  try:
870
884
  result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
871
885
  result["media_owner_paused"] = bool(restore_state)
@@ -1028,7 +1042,7 @@ class PreviewAgent:
1028
1042
  await asyncio.sleep(0.5)
1029
1043
  # I/O. Camera/mic join and the preset greeting are the critical path.
1030
1044
  asyncio.create_task(
1031
- asyncio.to_thread(_play_audio_effect, self.audio_output_device, "motion")
1045
+ asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
1032
1046
  )
1033
1047
  try:
1034
1048
  r = await self._session.post(
@@ -1234,7 +1248,7 @@ class LiveKitPublisher:
1234
1248
  # never actually reaching the real speakers. Prefer WASAPI's default output,
1235
1249
  # which is what Windows' own volume mixer/meter is backed by.
1236
1250
  output_device_index = None
1237
- selected_output = str(self.agent.audio_output_device or "default")
1251
+ selected_output = str(self.agent.audio_playback_device or "default")
1238
1252
  if selected_output.strip().isdigit():
1239
1253
  output_device_index = int(selected_output.strip())
1240
1254
  try:
@@ -578,6 +578,49 @@ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
578
578
  )
579
579
 
580
580
 
581
+ def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
582
+ channels: int = 1) -> tuple[bool, str]:
583
+ """Play PCM through the same ALSA plughw path proven during Pi setup.
584
+
585
+ PortAudio and RoboVision enumerate devices in different index spaces. The
586
+ inventory name contains the stable ALSA hw tuple, so use it directly and
587
+ let ALSA's plug layer adapt the cached TTS sample rate/channel count.
588
+ """
589
+ import re
590
+
591
+ match = re.search(r"hw:(\d+),(\d+)", str(selected_output), re.IGNORECASE)
592
+ if not match:
593
+ return False, "selected output has no ALSA hw address"
594
+ alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
595
+ command = [
596
+ "aplay", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
597
+ "-r", str(sample_rate), "-c", str(channels),
598
+ ]
599
+ last_error = "aplay failed"
600
+ # ALSA can retain an exclusive USB handle briefly after the LiveKit
601
+ # playback stream closes. Retry for a bounded period instead of declaring
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}"
621
+ return False, f"{alsa_device}: {last_error}"
622
+
623
+
581
624
  def _speaker_roundtrip_test(params: dict) -> dict:
582
625
  """Play a test tone + record the mic simultaneously; return metrics.
583
626
 
@@ -618,10 +661,46 @@ def _speaker_roundtrip_test(params: dict) -> dict:
618
661
  if dur > 10.0:
619
662
  return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
620
663
  n_samples = int(source_rate * dur)
621
- if n_samples < 480:
622
- return {"ok": False, "error": "duration too short"}
623
-
624
- pa = pyaudio.PyAudio()
664
+ if n_samples < 480:
665
+ return {"ok": False, "error": "duration too short"}
666
+
667
+ # Cached character voice is output-only. On Linux, bypass PortAudio's
668
+ # unrelated index space and use the exact ALSA plughw endpoint selected by
669
+ # RoboVision. This is the same path used by the successful onsite test.
670
+ if playback_only and mono_pcm is not None and sys.platform.startswith("linux"):
671
+ t0 = time.monotonic()
672
+ played, detail = _linux_aplay_pcm16(mono_pcm, str(out_name), source_rate)
673
+ duration_ms = int((time.monotonic() - t0) * 1000)
674
+ if not played:
675
+ return {
676
+ "ok": False,
677
+ "error": f"ALSA playback failed: {detail}",
678
+ "duration_ms": duration_ms,
679
+ "output_device": str(out_name),
680
+ "input_device": "not opened (speaker-only test)",
681
+ "duration": dur,
682
+ }
683
+ return {
684
+ "ok": True,
685
+ "pass": True,
686
+ "played": True,
687
+ "playback_only": True,
688
+ "mode": mode,
689
+ "text": params.get("text"),
690
+ "cache_hit": bool(params.get("cache_hit")),
691
+ "tts_provider": params.get("tts_provider"),
692
+ "tts_voice": params.get("tts_voice"),
693
+ "duration": dur,
694
+ "duration_ms": duration_ms,
695
+ "sample_rate": source_rate,
696
+ "output_sample_rate": source_rate,
697
+ "output_channels": 1,
698
+ "output_device": detail,
699
+ "input_device": "not opened (speaker-only test)",
700
+ "diagnostic": "cached voice played through the selected ALSA plughw endpoint",
701
+ }
702
+
703
+ pa = pyaudio.PyAudio()
625
704
  out_idx = _resolve_audio_device(pa, out_name, "output")
626
705
  in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
627
706
  def _dev_name(idx):