infinicode 2.8.79 → 2.8.81

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.
@@ -6819,6 +6819,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
6819
6819
  if(!result.queued) throw new Error(result.reason||'trigger was not queued');
6820
6820
  showOut('full production test queued for '+(n.label||n.name));
6821
6821
  pollPipeline(n);
6822
+ // The stream endpoint is initially idle and becomes active only after
6823
+ // the robot consumes the trigger. Retry through that transition.
6824
+ [400,1200,2500,4500].forEach(function(delay){setTimeout(function(){if(drawerId)startMicMonitor(n);},delay);});
6822
6825
  }).catch(function(e){ showOut('production test: '+e.message); pollPipeline(n); }).finally(function(){ if(start){ start.disabled=false; start.textContent='Start full test'; } });
6823
6826
  }
6824
6827
  function startPipelineTest(n){
@@ -6969,19 +6972,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
6969
6972
  var note=$('pk-camnote'), res=$('pk-camres');
6970
6973
  try{
6971
6974
  var LK=window.LivekitClient; lkRoom=new LK.Room();
6972
- lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
6975
+ lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track,publication,participant){
6973
6976
  if(track.kind==='video'){
6974
6977
  if(audioOnly)return;
6975
6978
  var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
6976
6979
  var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
6977
6980
  if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
6978
6981
  } else if(track.kind==='audio'){
6979
- // Listen only to the robot's published microphone. Agent TTS may
6980
- // also be present in the room, but hearing that directly would
6981
- // bypass the physical speaker -> microphone round-trip test.
6982
- if(track.name&&track.name!=='microphone')return;
6982
+ // Track names are backend-dependent ("mic", device labels, or
6983
+ // empty). Identify the robot by participant instead; reject the
6984
+ // voice worker/viewer so this remains the physical robot mic.
6985
+ var identity=String(participant&&participant.identity||'');
6986
+ var expected=String(schedId(n)||'').toLowerCase();
6987
+ var nodeName=String(n.name||'').toLowerCase();
6988
+ var lowerIdentity=identity.toLowerCase();
6989
+ var isWorker=/(agent|operator|viewer)/.test(lowerIdentity);
6990
+ var isRobot=!isWorker&&(lowerIdentity===expected||lowerIdentity===nodeName||lowerIdentity.indexOf(expected)>-1||lowerIdentity.indexOf(nodeName)>-1);
6991
+ if(!isRobot){if(window.console)console.debug('RoboPark ignored non-robot audio track',identity,track.name,publication&&publication.source);return;}
6983
6992
  hookMicAnalyser(track);
6984
- micMonitorState='live track published';
6993
+ micMonitorState='live robot mic from '+identity;
6985
6994
  var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · live track published';
6986
6995
  // Muted by default: this is the camera-preview panel, running on
6987
6996
  // the SAME physical device as the robot in local testing. Auto-
@@ -6991,9 +7000,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
6991
7000
  // that drowns out the actual conversation. The VU meter above
6992
7001
  // (hookMicAnalyser) taps the raw track directly and works fine
6993
7002
  // muted.
6994
- var audio=track.attach(); audio.autoplay=true; audio.controls=false; audio.muted=!micMonitorAudible; audio.volume=micMonitorVolume;audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
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);
6995
7004
  var listenNote=$('pk-mic-listen-note');if(listenNote&&micMonitorAudible)listenNote.textContent='Robot mic is playing in this browser. Use headphones.';
6996
- audio.play().catch(function(){});
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.';});
6997
7006
  }
6998
7007
  });
6999
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='—';});
@@ -7155,16 +7164,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
7155
7164
  var banner = result.pass
7156
7165
  ? '<div class="pk-spk-banner ok">✓ '+(isTts?'cached voice playback OK':'speaker round-trip OK')+'</div>'
7157
7166
  : '<div class="pk-spk-banner bad">✗ no signal detected</div>';
7158
- var meta = '<div class="pk-spk-meta">'
7159
- + '<div><span class="l">peak</span><span class="v">'+esc(result.recorded_peak_db+' dB')+'</span></div>'
7160
- + '<div><span class="l">RMS</span><span class="v">'+esc(result.recorded_rms_db+' dB')+'</span></div>'
7161
- + '<div><span class="l">threshold</span><span class="v">'+esc(result.threshold_db+' dB')+'</span></div>'
7167
+ var meta = '<div class="pk-spk-meta">'
7168
+ + (result.playback_only?'':'<div><span class="l">peak</span><span class="v">'+esc(result.recorded_peak_db+' dB')+'</span></div><div><span class="l">RMS</span><span class="v">'+esc(result.recorded_rms_db+' dB')+'</span></div><div><span class="l">threshold</span><span class="v">'+esc(result.threshold_db+' dB')+'</span></div>')
7162
7169
  + (isTts?'<div><span class="l">voice</span><span class="v">'+esc((result.tts_provider||'tts')+' / '+(result.tts_voice||'default'))+'</span></div><div><span class="l">cache</span><span class="v">'+(result.cache_hit?'hit':'generated')+'</span></div>':'<div><span class="l">frequency</span><span class="v">'+esc(Math.round(result.frequency)+' Hz')+'</span></div>')
7163
7170
  + '<div><span class="l">duration</span><span class="v">'+esc(result.duration+' s')+'</span></div>'
7164
7171
  + '<div><span class="l">latency</span><span class="v">'+esc(result.duration_ms+' ms')+'</span></div>'
7165
7172
  + '<div><span class="l">output</span><span class="v">'+esc(result.output_device)+'</span></div>'
7166
7173
  + '<div><span class="l">input</span><span class="v">'+esc(result.input_device)+'</span></div>'
7167
- + '<div><span class="l">format</span><span class="v">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out · '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in')+'</span></div>'
7174
+ + '<div><span class="l">format</span><span class="v">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out'+(result.playback_only?'':' · '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in'))+'</span></div>'
7168
7175
  + '</div>';
7169
7176
  var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
7170
7177
  host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
@@ -7187,7 +7194,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7187
7194
  // the real recording.
7188
7195
  _spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});
7189
7196
  rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',
7190
- {mode:mode, text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input, output_name:outputName, input_name:inputName})
7197
+ {mode:mode, playback_only:mode==='tts', text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input, output_name:outputName, input_name:inputName})
7191
7198
  .then(function(q){
7192
7199
  // long-poll up to 8s, then fall back to 12s of result polling
7193
7200
  if(status) status.textContent = mode==='tts'?'playing cached voice on '+selectedRobotOutput()+'…':'playing tone + recording…';
@@ -7233,7 +7240,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
7233
7240
  var status = $('pk-spk-test-stateline');
7234
7241
  if(status){
7235
7242
  if(!result || !result.ok){ status.textContent = '✗ test failed'; }
7236
- else if(result.pass){ status.textContent = '✓ pass · '+result.recorded_peak_db+' dB peak'; }
7243
+ else if(result.playback_only&&result.played){ status.textContent = '✓ speaker playback completed'; }
7244
+ else if(result.pass){ status.textContent = '✓ pass · '+result.recorded_peak_db+' dB peak'; }
7237
7245
  else { status.textContent = '✗ no signal — '+result.recorded_peak_db+' dB peak'; }
7238
7246
  }
7239
7247
  var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.79",
3
+ "version": "2.8.81",
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",
@@ -3477,6 +3477,7 @@ class SpeakerTestRequest(BaseModel):
3477
3477
  output_name: Optional[str] = None
3478
3478
  input_name: Optional[str] = None
3479
3479
  sample_rate: Optional[int] = None
3480
+ playback_only: Optional[bool] = None
3480
3481
 
3481
3482
 
3482
3483
  async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
@@ -3533,7 +3534,8 @@ async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
3533
3534
  async with aiosqlite.connect(DB_PATH) as db:
3534
3535
  db.row_factory = aiosqlite.Row
3535
3536
  async with db.execute(
3536
- "SELECT id FROM devices WHERE id = ? OR lower(name) = lower(?) "
3537
+ "SELECT id, audio_device, audio_output_device, device_inventory FROM devices "
3538
+ "WHERE id = ? OR lower(name) = lower(?) "
3537
3539
  "ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END LIMIT 1",
3538
3540
  (robot_id, robot_id, robot_id),
3539
3541
  ) as cursor:
@@ -3542,7 +3544,30 @@ async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
3542
3544
  raise HTTPException(404, f"Robot device not found: {robot_id}")
3543
3545
  device_id = device["id"]
3544
3546
  params = payload.model_dump(exclude_none=True)
3547
+ # Scheduler persistence is authoritative. Dashboard state can be stale
3548
+ # while heartbeat polling rebuilds the drawer, so never let it silently
3549
+ # route a test to a different device than the one the operator saved.
3550
+ if device["audio_device"] not in (None, ""):
3551
+ params["input"] = str(device["audio_device"])
3552
+ if device["audio_output_device"] not in (None, ""):
3553
+ params["output"] = str(device["audio_output_device"])
3554
+ try:
3555
+ inventory = json.loads(device["device_inventory"] or "{}")
3556
+ except (TypeError, ValueError):
3557
+ inventory = {}
3558
+ for field, inventory_key, name_key in (
3559
+ ("input", "audio_input", "input_name"),
3560
+ ("output", "audio_output", "output_name"),
3561
+ ):
3562
+ selected = str(params.get(field, ""))
3563
+ for item in inventory.get(inventory_key, []) or []:
3564
+ if str(item.get("id", "")) == selected:
3565
+ params[name_key] = str(item.get("name") or selected)
3566
+ break
3545
3567
  if payload.mode == "tts":
3568
+ # Cached voice is a speaker test. Do not also seize the microphone,
3569
+ # which may be owned by the live preview/conversation publisher.
3570
+ params.setdefault("playback_only", True)
3546
3571
  params.update(await _cached_speaker_tts(robot_id, payload.text or ""))
3547
3572
  elif payload.mode != "tone":
3548
3573
  raise HTTPException(422, "mode must be tone or tts")
@@ -392,6 +392,10 @@ class PreviewAgent:
392
392
  self.enrollment_token: Optional[str] = cfg.get("enrollment_token")
393
393
  self.video_device = cfg.get("video_device", os.getenv("VIDEO_DEVICE", "auto"))
394
394
  self.audio_device = cfg.get("audio_device", os.getenv("AUDIO_DEVICE", "default"))
395
+ # Scheduler inventory IDs come from RoboVision/sounddevice. They are
396
+ # not guaranteed to equal PyAudio's device indexes, so capture must
397
+ # use the resolved hardware name reported in that same inventory.
398
+ self.audio_capture_device = self.audio_device
395
399
  self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
396
400
  self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
397
401
  self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
@@ -672,6 +676,16 @@ class PreviewAgent:
672
676
  if data.get("audio_device") is not None:
673
677
  changed = changed or self.audio_device != data["audio_device"]
674
678
  self.audio_device = data["audio_device"]
679
+ inventory = data.get("device_inventory") or {}
680
+ selected = str(self.audio_device)
681
+ self.audio_capture_device = next(
682
+ (
683
+ str(item.get("name"))
684
+ for item in inventory.get("audio_input", [])
685
+ if str(item.get("id")) == selected and item.get("name")
686
+ ),
687
+ self.audio_device,
688
+ )
675
689
  if data.get("audio_output_device") is not None:
676
690
  changed = changed or self.audio_output_device != data["audio_output_device"]
677
691
  self.audio_output_device = data["audio_output_device"]
@@ -1457,7 +1471,7 @@ class LiveKitPublisher:
1457
1471
 
1458
1472
  async def _audio_loop(self) -> None:
1459
1473
  assert self.audio_source is not None
1460
- mic = create_audio_capture(self.agent.audio_device)
1474
+ mic = create_audio_capture(self.agent.audio_capture_device)
1461
1475
  if mic is None:
1462
1476
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
1463
1477
  return
@@ -588,6 +588,7 @@ def _speaker_roundtrip_test(params: dict) -> dict:
588
588
  except ImportError as e:
589
589
  return {"ok": False, "error": f"pyaudio not installed on this robot: {e}"}
590
590
  mode = str(params.get("mode", "tone")).lower()
591
+ playback_only = bool(params.get("playback_only", mode == "tts"))
591
592
  freq = float(params.get("frequency", SPEAKER_TEST_FREQUENCY_HZ))
592
593
  dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
593
594
  amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
@@ -622,13 +623,13 @@ def _speaker_roundtrip_test(params: dict) -> dict:
622
623
 
623
624
  pa = pyaudio.PyAudio()
624
625
  out_idx = _resolve_audio_device(pa, out_name, "output")
625
- in_idx = _resolve_audio_device(pa, in_name, "input")
626
+ in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
626
627
  def _dev_name(idx):
627
628
  if idx is None: return "(default)"
628
629
  try: return pa.get_device_info_by_index(idx).get("name", str(idx))
629
630
  except Exception: return str(idx)
630
631
  out_label = _dev_name(out_idx)
631
- in_label = _dev_name(in_idx)
632
+ in_label = "not opened (speaker-only test)" if playback_only else _dev_name(in_idx)
632
633
 
633
634
  recorded_peak = 0
634
635
  recorded_rms = 0.0
@@ -644,11 +645,12 @@ def _speaker_roundtrip_test(params: dict) -> dict:
644
645
  out_stream, output_rate, output_channels, out_info = _open_pcm_stream(
645
646
  pa, pyaudio, "output", out_idx, source_rate
646
647
  )
647
- in_stream, input_rate, input_channels, in_info = _open_pcm_stream(
648
- pa, pyaudio, "input", in_idx, source_rate
649
- )
650
648
  out_label = str(out_info.get("name", out_label))
651
- in_label = str(in_info.get("name", in_label))
649
+ if not playback_only:
650
+ in_stream, input_rate, input_channels, in_info = _open_pcm_stream(
651
+ pa, pyaudio, "input", in_idx, source_rate
652
+ )
653
+ in_label = str(in_info.get("name", in_label))
652
654
 
653
655
  if mono_pcm is not None:
654
656
  output_mono = _resample_pcm16_mono(mono_pcm, source_rate, output_rate)
@@ -671,7 +673,8 @@ def _speaker_roundtrip_test(params: dict) -> dict:
671
673
  raw = output_mono
672
674
 
673
675
  chunk = 1024
674
- in_stream.start_stream()
676
+ if in_stream is not None:
677
+ in_stream.start_stream()
675
678
  chunk_bytes = chunk * 2 * output_channels
676
679
  pos = 0
677
680
  peak_acc = 0
@@ -682,8 +685,10 @@ def _speaker_roundtrip_test(params: dict) -> dict:
682
685
  end = min(pos + chunk_bytes, len(raw))
683
686
  out_stream.write(raw[pos:end])
684
687
  pos = end
685
- try:
686
- avail = in_stream.get_read_available()
688
+ try:
689
+ if in_stream is None:
690
+ continue
691
+ avail = in_stream.get_read_available()
687
692
  if avail and avail > 0:
688
693
  data = in_stream.read(avail, exception_on_overflow=False)
689
694
  for s in range(0, len(data) - 1, 2):
@@ -695,18 +700,20 @@ def _speaker_roundtrip_test(params: dict) -> dict:
695
700
  except Exception:
696
701
  pass
697
702
  out_stream.stop_stream(); out_stream.close(); out_stream = None
698
- try:
699
- tail_frames = min(int(input_rate * 0.35), max(0, in_stream.get_read_available()))
700
- tail = in_stream.read(tail_frames, exception_on_overflow=False) if tail_frames else b""
701
- for s in range(0, len(tail) - 1, 2):
702
- v = int.from_bytes(tail[s:s+2], "little", signed=True)
703
- a = abs(v)
704
- if a > peak_acc: peak_acc = a
705
- rms_acc += v * v
706
- n_samp += 1
707
- except Exception:
708
- pass
709
- in_stream.stop_stream(); in_stream.close(); in_stream = None
703
+ if in_stream is not None:
704
+ try:
705
+ tail_frames = min(int(input_rate * 0.35), max(0, in_stream.get_read_available()))
706
+ tail = in_stream.read(tail_frames, exception_on_overflow=False) if tail_frames else b""
707
+ for s in range(0, len(tail) - 1, 2):
708
+ v = int.from_bytes(tail[s:s+2], "little", signed=True)
709
+ a = abs(v)
710
+ if a > peak_acc: peak_acc = a
711
+ rms_acc += v * v
712
+ n_samp += 1
713
+ except Exception:
714
+ pass
715
+ if in_stream is not None:
716
+ in_stream.stop_stream(); in_stream.close(); in_stream = None
710
717
  recorded_peak = peak_acc
711
718
  recorded_rms = (rms_acc / max(1, n_samp)) ** 0.5
712
719
  except Exception as e:
@@ -721,10 +728,30 @@ def _speaker_roundtrip_test(params: dict) -> dict:
721
728
  try: pa.terminate()
722
729
  except Exception: pass
723
730
  duration_ms = int((time.monotonic() - t0) * 1000)
724
- if err:
731
+ if err:
725
732
  return {"ok": False, "error": err, "duration_ms": duration_ms,
726
733
  "output_device": out_label, "input_device": in_label,
727
- "frequency": freq, "duration": dur}
734
+ "frequency": freq, "duration": dur}
735
+ if playback_only:
736
+ return {
737
+ "ok": True,
738
+ "pass": True,
739
+ "played": True,
740
+ "playback_only": True,
741
+ "mode": mode,
742
+ "text": params.get("text"),
743
+ "cache_hit": bool(params.get("cache_hit")),
744
+ "tts_provider": params.get("tts_provider"),
745
+ "tts_voice": params.get("tts_voice"),
746
+ "duration": dur,
747
+ "duration_ms": duration_ms,
748
+ "sample_rate": source_rate,
749
+ "output_sample_rate": output_rate,
750
+ "output_channels": output_channels,
751
+ "output_device": out_label,
752
+ "input_device": in_label,
753
+ "diagnostic": "speaker playback completed; microphone was intentionally not opened",
754
+ }
728
755
  if recorded_peak > 0:
729
756
  peak_db = 20.0 * math.log10(recorded_peak / 32767.0)
730
757
  else: