infinicode 2.8.76 → 2.8.78

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.
@@ -1083,7 +1083,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
1083
1083
 
1084
1084
  <!-- ═══ Setup ═══ -->
1085
1085
  <div class="rp-sub" id="rp-sub-setup">
1086
- <!-- Add LiveKit server -->
1086
+ <div class="rp-form" id="rp-provider-secrets">
1087
+ <h3>Voice provider secret</h3>
1088
+ <div class="rp-quickrow">
1089
+ <div class="rp-field" style="flex:1"><label>ElevenLabs API key</label><input type="password" id="rp-elevenlabs-key" autocomplete="new-password" placeholder="stored only on the scheduler"></div>
1090
+ <button class="rp-btn primary" id="rp-elevenlabs-save" style="height:fit-content">Save secret</button>
1091
+ </div>
1092
+ <div class="rp-autonote" id="rp-elevenlabs-status">not configured · cached voice speaker tests require this key</div>
1093
+ </div>
1094
+
1095
+ <!-- Add LiveKit server -->
1087
1096
  <div class="rp-form" id="rp-addserver">
1088
1097
  <h3>Add LiveKit server</h3>
1089
1098
  <div class="rp-quickrow">
@@ -1893,11 +1902,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
1893
1902
  .then(function(){ pmBusy=false; applyReadOnly(); });
1894
1903
  });
1895
1904
  })();
1896
- function pollSettings(){
1897
- fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
1898
- if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
1899
- }).catch(function(){});
1900
- }
1905
+ function pollSettings(){
1906
+ fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
1907
+ if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
1908
+ var providerStatus=$('rp-elevenlabs-status');
1909
+ if(providerStatus) providerStatus.textContent=s&&s.elevenlabs_configured
1910
+ ? 'configured · source: '+(s.elevenlabs_source==='environment'?'scheduler environment':'persistent scheduler store')
1911
+ : 'not configured · cached voice speaker tests require this key';
1912
+ }).catch(function(){});
1913
+ }
1914
+
1915
+ var elevenlabsSave=$('rp-elevenlabs-save');
1916
+ if(elevenlabsSave) elevenlabsSave.addEventListener('click',function(){
1917
+ var input=$('rp-elevenlabs-key'), key=input?input.value.trim():'';
1918
+ if(key.length<8){ showOut('Enter a valid ElevenLabs API key'); return; }
1919
+ elevenlabsSave.disabled=true;
1920
+ rpAction('POST','/robopark/api/settings/provider-secret',{provider:'elevenlabs',api_key:key}).then(function(){
1921
+ input.value=''; showOut('ElevenLabs secret saved on scheduler'); pollSettings();
1922
+ }).catch(function(e){ showOut('ElevenLabs secret: '+e.message); }).then(function(){elevenlabsSave.disabled=false;});
1923
+ });
1901
1924
 
1902
1925
  function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }
1903
1926
  function endSession(id){
@@ -6190,6 +6213,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
6190
6213
  if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }
6191
6214
  var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');
6192
6215
  visionOn=true;
6216
+ directImg.style.display='block';
6217
+ if(directCamNote)directCamNote.textContent='opening RoboVision camera relay...';
6218
+ if(directRes)directRes.textContent='CONNECTING';
6193
6219
  directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; directImg.src=''; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable on robot'; if(directLiveNote) directLiveNote.textContent='camera unavailable'; if(directRes) directRes.textContent='OFFLINE'; };
6194
6220
  directImg.onload=function(){ directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
6195
6221
  directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
@@ -6244,13 +6270,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6244
6270
  var video=deviceList(inv,'video',['auto','none']);
6245
6271
  var input=deviceList(inv,'audio_input',['default','none']);
6246
6272
  var output=deviceList(inv,'audio_output',['default','none']);
6247
- function fill(id, list, selected){
6248
- var el=$(id); if(!el) return;
6249
- el.innerHTML='';
6250
- list.filter(function(x){ return !query || String(x.name||x.id).toLowerCase().indexOf(query)>-1 || String(x.id).toLowerCase().indexOf(query)>-1; }).forEach(function(x){
6251
- var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(selected||'')) o.selected=true; el.appendChild(o);
6252
- });
6253
- if(!el.value && selected){ var o=document.createElement('option'); o.value=String(selected); o.textContent=String(selected)+' (saved)'; o.selected=true; el.appendChild(o); }
6273
+ function fill(id, list, selected){
6274
+ var el=$(id); if(!el) return;
6275
+ // Heartbeat polling rebuilds this panel frequently. Preserve an
6276
+ // operator's unsaved choice instead of snapping back to the last
6277
+ // scheduler value while they move between camera/mic/speaker fields.
6278
+ var effectiveSelected=el.dataset.dirty==='true'?el.value:selected;
6279
+ el.innerHTML='';
6280
+ list.filter(function(x){ return !query || String(x.name||x.id).toLowerCase().indexOf(query)>-1 || String(x.id).toLowerCase().indexOf(query)>-1; }).forEach(function(x){
6281
+ var o=document.createElement('option'); o.value=String(x.id); o.textContent=String(x.name||x.id); if(String(x.id)===String(effectiveSelected||'')) o.selected=true; el.appendChild(o);
6282
+ });
6283
+ if(!el.value && effectiveSelected){ var o=document.createElement('option'); o.value=String(effectiveSelected); o.textContent=String(effectiveSelected)+(el.dataset.dirty==='true'?' (selected)':' (saved)'); o.selected=true; el.appendChild(o); }
6254
6284
  }
6255
6285
  fill('pk-video-device', video, device.video_device||'auto');
6256
6286
  fill('pk-audio-input', input, device.audio_device||'default');
@@ -6270,13 +6300,27 @@ button.act:disabled{opacity:0.5;cursor:default;}
6270
6300
  note.textContent=hasRealInventory(inv) ? ('inventory from '+source+' · camera/mic/speaker '+counts) : (device.id ? 'no real hardware inventory reported yet · restart vision-agent + preview-agent on this robot' : 'waiting for robot heartbeat inventory');
6271
6301
  }
6272
6302
  }
6273
- function saveDeviceSelectors(n){
6274
- var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
6275
- var body={video_device:$('pk-video-device').value, audio_device:$('pk-audio-input').value, audio_output_device:$('pk-audio-output').value,
6276
- greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};
6277
- rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(){
6278
- showOut('camera and microphone selection saved for '+(device.name||id));
6279
- }).catch(function(e){ showOut('device selection: '+e.message); });
6303
+ function saveDeviceSelectors(n){
6304
+ var device=schedulerDevice(n), id=device&&device.id; if(!id){ showOut('device inventory is not available yet'); return; }
6305
+ var body={video_device:$('pk-video-device').value, audio_device:$('pk-audio-input').value, audio_output_device:$('pk-audio-output').value,
6306
+ greeting_phrases:($('pk-greeting-phrases').value||'').split(String.fromCharCode(10)).map(function(x){return x.trim();}).filter(Boolean)};
6307
+ var button=$('pk-device-save'),note=$('pk-device-note');
6308
+ if(button){button.disabled=true;button.textContent='Saving...';}
6309
+ if(note)note.textContent='saving device selection to scheduler...';
6310
+ rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(id),body).then(function(saved){
6311
+ // Keep the drawer's local scheduler record current immediately. The
6312
+ // next /devices poll will confirm the same values from persistent DB.
6313
+ Object.assign(device,saved||body);
6314
+ ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){var el=$(field);if(el)delete el.dataset.dirty;});
6315
+ refreshDeviceSelectors(n);
6316
+ if(note)note.textContent='saved · robot will apply selection on its next config heartbeat';
6317
+ if(button){button.textContent='Saved';setTimeout(function(){if($('pk-device-save')===button)button.textContent='Save device selection + greetings';},1400);}
6318
+ showOut('camera, microphone, and speaker selection saved for '+(device.name||id));
6319
+ }).catch(function(e){
6320
+ if(note)note.textContent='save failed · '+e.message;
6321
+ if(button)button.textContent='Retry save';
6322
+ showOut('device selection: '+e.message);
6323
+ }).then(function(){if(button)button.disabled=false;});
6280
6324
  }
6281
6325
  function fmtUptime(secs){
6282
6326
  if(secs==null) return '';
@@ -6679,7 +6723,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6679
6723
  b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
6680
6724
  var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
6681
6725
  var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
6682
- var outputSelect=$('pk-audio-output'); if(outputSelect) outputSelect.addEventListener('change', function(){ refreshDeviceSelectors(n); });
6726
+ ['pk-video-device','pk-audio-input','pk-audio-output'].forEach(function(field){
6727
+ var select=$(field);if(!select)return;
6728
+ select.addEventListener('change',function(){
6729
+ select.dataset.dirty='true';
6730
+ var note=$('pk-device-note');if(note)note.textContent='unsaved selection · click Save device selection + greetings';
6731
+ if(field==='pk-audio-output'){
6732
+ var selectedLabel=$('pk-spk-selected'),option=select.options&&select.selectedIndex>=0?select.options[select.selectedIndex]:null;
6733
+ if(selectedLabel)selectedLabel.textContent=option?String(option.textContent||option.value):String(select.value||'default');
6734
+ }
6735
+ });
6736
+ });
6683
6737
  var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});
6684
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;});});
6685
6739
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
@@ -6879,7 +6933,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6879
6933
  .catch(function(){ idleCam('unreachable'); });
6880
6934
  }
6881
6935
  function idleCam(reason){
6882
- var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
6936
+ var cc=$('pk-camcv'), msg={connecting:'connecting to RoboVision camera relay…', idle:'camera relay standing by', production_mode_off:'production mode off', 'no-sdk':'voice session active · loading microphone…', unreachable:'camera relay unreachable'}[reason]||'no live feed';
6883
6937
  if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }
6884
6938
  var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
6885
6939
  var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
@@ -6985,7 +7039,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6985
7039
  (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
6986
7040
  // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
6987
7041
  var lvl=0;
6988
- if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }
7042
+ if(micAnalyser){
7043
+ micAnalyser.getByteTimeDomainData(buf);
7044
+ var sum=0;
7045
+ for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; }
7046
+ var rms=Math.sqrt(sum/buf.length);
7047
+ // USB microphones commonly sit around -55 dBFS at room level and
7048
+ // -20 dBFS when spoken into. Map that useful range to 0..100 rather
7049
+ // than displaying normalized RMS directly (which looked stuck at 1%).
7050
+ var db=rms>0 ? 20*Math.log10(rms) : -90;
7051
+ lvl=Math.max(0,Math.min(1,(db+60)/45));
7052
+ }
6989
7053
  for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }
6990
7054
  set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
6991
7055
  set('pk-micstate', micAnalyser?('selected '+selectedRobotMic()+' · '+(lvl>0.12?'live, hearing audio':'live, quiet')):('selected '+selectedRobotMic()+' · '+micMonitorState));
@@ -7099,8 +7163,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
7099
7163
  + '<div><span class="l">duration</span><span class="v">'+esc(result.duration+' s')+'</span></div>'
7100
7164
  + '<div><span class="l">latency</span><span class="v">'+esc(result.duration_ms+' ms')+'</span></div>'
7101
7165
  + '<div><span class="l">output</span><span class="v">'+esc(result.output_device)+'</span></div>'
7102
- + '<div><span class="l">input</span><span class="v">'+esc(result.input_device)+'</span></div>'
7103
- + '</div>';
7166
+ + '<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>'
7168
+ + '</div>';
7104
7169
  var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
7105
7170
  host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7106
7171
  + banner + meta + diag + '</div>';
@@ -7116,12 +7181,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
7116
7181
  var phrase=String((($('pk-spk-phrase')||{}).value)||'Hello, I am ready to talk.').trim();
7117
7182
  var output=(($('pk-audio-output')||{}).value)||'default';
7118
7183
  var input=(($('pk-audio-input')||{}).value)||'default';
7184
+ var outputName=selectedRobotOutput(),inputName=selectedRobotMic();
7119
7185
  // Animate the meter with a synthetic "during-test" peak so the
7120
7186
  // operator sees something happen immediately while waiting for
7121
7187
  // the real recording.
7122
7188
  _spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});
7123
7189
  rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',
7124
- {mode:mode, text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input})
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})
7125
7191
  .then(function(q){
7126
7192
  // long-poll up to 8s, then fall back to 12s of result polling
7127
7193
  if(status) status.textContent = mode==='tts'?'playing cached voice on '+selectedRobotOutput()+'…':'playing tone + recording…';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.76",
3
+ "version": "2.8.78",
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",
@@ -69,7 +69,14 @@ ELEVENLABS_API_KEY = (
69
69
  or os.getenv("ELEVENLABS_KEY", "").strip()
70
70
  or os.getenv("ELEVEN_API_KEY", "").strip()
71
71
  )
72
- ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
72
+ ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
73
+
74
+
75
+ async def _get_elevenlabs_api_key() -> str:
76
+ """Resolve the provider key without ever returning it through public APIs."""
77
+ if ELEVENLABS_API_KEY:
78
+ return ELEVENLABS_API_KEY
79
+ return (await get_setting("elevenlabs_api_key") or "").strip()
73
80
 
74
81
  # Optional base URL of a local Kokoro TTS server. When set, /api/voices/kokoro
75
82
  # will proxy its /v1/audio/voices endpoint to return the dynamic list of voices
@@ -1991,12 +1998,29 @@ async def get_settings():
1991
1998
  """Public settings with secret values reduced to status only."""
1992
1999
  pm = await get_production_mode()
1993
2000
  has_default_token = bool(await get_setting("default_enrollment_token"))
2001
+ elevenlabs_key = await _get_elevenlabs_api_key()
1994
2002
  return {
1995
2003
  "production_mode": pm,
1996
2004
  "default_enrollment_token_set": has_default_token,
1997
2005
  "agent_token_configured": bool(ROBOPARK_AGENT_TOKEN),
1998
2006
  "agent_token_source": "environment" if os.getenv("ROBOPARK_AGENT_TOKEN", "").strip() else "scheduler_secret_file",
2007
+ "elevenlabs_configured": bool(elevenlabs_key),
2008
+ "elevenlabs_source": "environment" if ELEVENLABS_API_KEY else ("scheduler_store" if elevenlabs_key else None),
1999
2009
  }
2010
+
2011
+
2012
+ @app.post("/api/settings/provider-secret")
2013
+ async def set_provider_secret(payload: dict):
2014
+ """Persist a provider credential while only exposing configured status."""
2015
+ provider = str(payload.get("provider") or "").strip().lower()
2016
+ api_key = str(payload.get("api_key") or "").strip()
2017
+ if provider != "elevenlabs":
2018
+ raise HTTPException(400, "unsupported provider")
2019
+ if len(api_key) < 8:
2020
+ raise HTTPException(400, "ElevenLabs API key must be at least 8 characters")
2021
+ await set_setting("elevenlabs_api_key", api_key)
2022
+ await _log_history("settings", "provider_secret", provider, "updated", "operator")
2023
+ return {"ok": True, "provider": provider, "configured": True, "source": "scheduler_store"}
2000
2024
 
2001
2025
  @app.put("/api/settings")
2002
2026
  async def update_settings(payload: SettingsPayload):
@@ -3448,8 +3472,10 @@ class SpeakerTestRequest(BaseModel):
3448
3472
  duration: Optional[float] = None
3449
3473
  amplitude: Optional[float] = None
3450
3474
  threshold_db: Optional[float] = None
3451
- output: Optional[str] = None
3452
- input: Optional[str] = None
3475
+ output: Optional[str] = None
3476
+ input: Optional[str] = None
3477
+ output_name: Optional[str] = None
3478
+ input_name: Optional[str] = None
3453
3479
  sample_rate: Optional[int] = None
3454
3480
 
3455
3481
 
@@ -3462,8 +3488,9 @@ async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
3462
3488
  voice_id = stack.tts_voice if stack else "21m00Tcm4TlvDq8ikWAM"
3463
3489
  if provider != "elevenlabs":
3464
3490
  raise HTTPException(400, f"Cached voice test currently requires ElevenLabs; selected stack uses {provider}")
3465
- if not ELEVENLABS_API_KEY:
3466
- raise HTTPException(503, "ELEVENLABS_API_KEY is not configured on the scheduler")
3491
+ elevenlabs_key = await _get_elevenlabs_api_key()
3492
+ if not elevenlabs_key:
3493
+ raise HTTPException(503, "ElevenLabs is not configured; open Control Center > Setup > Voice provider secret")
3467
3494
  if not voice_id:
3468
3495
  raise HTTPException(400, "Selected voice stack has no ElevenLabs voice id")
3469
3496
  cache_dir = os.path.join(os.path.dirname(DB_PATH), "tts-test-cache")
@@ -3479,7 +3506,7 @@ async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
3479
3506
  response = await client.post(
3480
3507
  f"{ELEVENLABS_BASE_URL}/v1/text-to-speech/{voice_id}",
3481
3508
  params={"output_format": "pcm_24000"},
3482
- headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "audio/pcm"},
3509
+ headers={"xi-api-key": elevenlabs_key, "Accept": "audio/pcm"},
3483
3510
  json={"text": phrase, "model_id": "eleven_flash_v2_5"},
3484
3511
  )
3485
3512
  if response.status_code != 200:
@@ -846,7 +846,18 @@ class PreviewAgent:
846
846
  return
847
847
  self._speaker_test_in_flight = True
848
848
  from robot_supervisor import _speaker_roundtrip_test
849
- result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
849
+ # LiveKit owns the USB microphone continuously. Release that owner
850
+ # for an explicit hardware test, then restore the same session.
851
+ restore_state = self._current_state if self._publisher else None
852
+ if restore_state:
853
+ await self._stop_publisher()
854
+ await asyncio.sleep(0.35)
855
+ try:
856
+ result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
857
+ result["media_owner_paused"] = bool(restore_state)
858
+ finally:
859
+ if restore_state and restore_state.active:
860
+ await self._start_publisher(restore_state)
850
861
  result_response = await self._session.post(
851
862
  f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/supervisor-output",
852
863
  json={"kind": "speaker_test", "service": None, "payload": result,
@@ -1092,8 +1103,9 @@ class LiveKitPublisher:
1092
1103
  self.audio_source: Optional[rtc.AudioSource] = None
1093
1104
  self.audio_track: Optional[rtc.LocalAudioTrack] = None
1094
1105
  self._stop_event = asyncio.Event()
1095
- self._tasks: list[asyncio.Task] = []
1096
- self._capture: Optional["VideoCapture"] = None
1106
+ self._tasks: list[asyncio.Task] = []
1107
+ self._capture: Optional["VideoCapture"] = None
1108
+ self._mic_capture: Optional["AudioCapture"] = None
1097
1109
  # Motion sampling state belongs to the publisher instance. Keeping it
1098
1110
  # initialized here prevents shutdown/reopen paths from raising while
1099
1111
  # the camera is being handed between preview and voice sessions.
@@ -1373,7 +1385,7 @@ class LiveKitPublisher:
1373
1385
  out.close()
1374
1386
  pa.terminate()
1375
1387
 
1376
- async def stop(self) -> None:
1388
+ async def stop(self) -> None:
1377
1389
  self._stop_event.set()
1378
1390
  for t in self._tasks:
1379
1391
  t.cancel()
@@ -1381,7 +1393,10 @@ class LiveKitPublisher:
1381
1393
  await t
1382
1394
  except asyncio.CancelledError:
1383
1395
  pass
1384
- self._tasks.clear()
1396
+ self._tasks.clear()
1397
+ if self._mic_capture:
1398
+ await asyncio.to_thread(self._mic_capture.stop)
1399
+ self._mic_capture = None
1385
1400
  if self._capture:
1386
1401
  self._capture.stop()
1387
1402
  self._capture = None
@@ -1442,10 +1457,11 @@ class LiveKitPublisher:
1442
1457
 
1443
1458
  async def _audio_loop(self) -> None:
1444
1459
  assert self.audio_source is not None
1445
- mic = create_audio_capture(self.agent.audio_device)
1446
- if mic is None:
1460
+ mic = create_audio_capture(self.agent.audio_device)
1461
+ if mic is None:
1447
1462
  logger.warning("audio_loop: create_audio_capture returned None, mic will not publish")
1448
- return
1463
+ return
1464
+ self._mic_capture = mic
1449
1465
  logger.info(f"audio_loop: mic capture started ({type(mic).__name__})")
1450
1466
  # Read in bigger batches (100ms) instead of one 20ms frame per
1451
1467
  # asyncio.to_thread() dispatch. Each dispatch/poll cycle has a fixed