infinicode 2.8.72 → 2.8.75

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.
@@ -24,9 +24,10 @@
24
24
  export const DASHBOARD_HTML = `<!doctype html>
25
25
  <html lang="en">
26
26
  <head>
27
- <meta charset="utf-8">
28
- <meta name="viewport" content="width=device-width, initial-scale=1">
29
- <title>RoboPark Control</title>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1">
29
+ <meta name="robopark-ui-build" content="voice-media-20260716-3">
30
+ <title>RoboPark Control</title>
30
31
  <style>
31
32
  /* ── premium tech glass theme ── dark is the committed default; light is a frosted toggle ── */
32
33
  :root{
@@ -690,6 +691,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
690
691
  @media(max-width:900px){.rp-config-columns{grid-template-columns:1fr}.rp-config-hero{align-items:flex-start;flex-direction:column}.rp-config-cards{max-height:430px}}
691
692
  @media(max-width:620px){.rp-config-fields{grid-template-columns:1fr}.rp-config-fields .wide{grid-column:auto}.rp-config-pipeline{grid-template-columns:1fr}.rp-config-pipeline i{display:none}.rp-config-hero-actions{width:100%}.rp-config-hero-actions .rp-btn{flex:1}}
692
693
 
694
+ /* command sheet */
695
+ .rp-command-shell{display:grid;gap:1rem}.rp-command-env{display:grid;grid-template-columns:repeat(3,minmax(170px,1fr));gap:.65rem;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}
696
+ .rp-command-toolbar{display:flex;gap:.55rem;align-items:center;flex-wrap:wrap}.rp-command-toolbar input{flex:1;min-width:220px}.rp-command-toolbar select{width:auto;min-width:170px}
697
+ .rp-command-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.75rem}.rp-command-card{display:flex;flex-direction:column;gap:.55rem;min-width:0;padding:.85rem;border:1px solid var(--glass-border);border-radius:16px;background:linear-gradient(145deg,rgba(31,26,18,.66),rgba(9,8,6,.76));box-shadow:inset 0 1px 0 var(--glass-hi),0 12px 32px rgba(0,0,0,.22)}
698
+ .rp-command-head{display:flex;align-items:flex-start;justify-content:space-between;gap:.6rem}.rp-command-head b{font-family:var(--mono);font-size:.74rem;color:var(--gold-pale)}.rp-command-tag{font-family:var(--mono);font-size:.52rem;text-transform:uppercase;letter-spacing:.06em;color:var(--gold-bright);border:1px solid var(--border-strong);border-radius:100px;padding:.15rem .42rem;white-space:nowrap}.rp-command-card p{font-size:.64rem;line-height:1.5;color:var(--muted);min-height:2.9em}
699
+ .rp-command-code{position:relative;min-height:86px;padding:.68rem .72rem;border:1px solid rgba(255,224,151,.1);border-radius:11px;background:#050504;overflow:auto}.rp-command-code code{display:block;font-family:var(--mono);font-size:.59rem;line-height:1.55;color:#f2dfab;white-space:pre-wrap;overflow-wrap:anywhere}.rp-command-actions{display:flex;gap:.4rem;align-items:center;margin-top:auto}.rp-command-actions .rp-btn:first-child{flex:1}.rp-command-empty{grid-column:1/-1;padding:2rem;text-align:center;color:var(--muted);border:1px dashed var(--glass-border);border-radius:16px}
700
+ .rp-command-custom{display:grid;grid-template-columns:1fr 1fr;gap:.65rem;padding:.9rem;border:1px solid var(--glass-border);border-radius:16px;background:var(--panel-2)}.rp-command-custom h4,.rp-command-custom .wide{grid-column:1/-1}.rp-command-custom h4{font-family:var(--mono);font-size:.72rem;color:var(--gold-bright)}.rp-command-custom textarea{min-height:92px;resize:vertical}.rp-command-note{font-size:.61rem;color:var(--muted)}
701
+ @media(max-width:800px){.rp-command-env{grid-template-columns:1fr 1fr}.rp-command-grid{grid-template-columns:1fr}}@media(max-width:540px){.rp-command-env,.rp-command-custom{grid-template-columns:1fr}.rp-command-custom h4,.rp-command-custom .wide{grid-column:auto}}
702
+
693
703
  .rp-fleet-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(290px,1fr));gap:0.7rem;padding:0;}
694
704
  .rp-fleet-card{position:relative;}
695
705
  .rp-fleet-card .spark{margin-top:0.5rem;height:36px;}
@@ -830,6 +840,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
830
840
  <button class="rp-subtab" data-sub="audit" id="rpsbtn-audit" role="tab" aria-selected="false">Audit Log</button>
831
841
  <button class="rp-subtab" data-sub="fleet" id="rpsbtn-fleet" role="tab" aria-selected="false">Fleet</button>
832
842
  <button class="rp-subtab" data-sub="config" id="rpsbtn-config" role="tab" aria-selected="false">Characters &amp; Voice</button>
843
+ <button class="rp-subtab" data-sub="commands" id="rpsbtn-commands" role="tab" aria-selected="false">Commands</button>
833
844
  <button class="rp-subtab" data-sub="setup" id="rpsbtn-setup" role="tab" aria-selected="false">Setup</button>
834
845
  </div>
835
846
 
@@ -896,7 +907,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
896
907
  <div class="rp-call-device"><label>Camera</label><select id="rp-call-cam-device"><option value="">system default</option></select></div>
897
908
  <div class="rp-call-device"><label>Speaker output</label><select id="rp-call-speaker-device"><option value="">system default</option></select></div>
898
909
  <div class="rp-call-device-note" id="rp-call-device-note">Enable media access to reveal the actual microphone, camera, and speaker names available to this browser.</div>
899
- <div class="rp-call-device-actions"><button class="rp-btn primary" id="rp-call-refresh-devices">Enable &amp; scan</button><button class="rp-btn" id="rp-call-test-mic">Test microphone</button></div>
910
+ <div class="rp-call-device-actions"><button class="rp-btn primary" id="rp-call-refresh-devices">Enable media access</button><button class="rp-btn" id="rp-call-test-mic">Test microphone</button></div>
900
911
  </div>
901
912
  </aside>
902
913
  <main class="rp-call-stage">
@@ -1071,7 +1082,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1071
1082
  </div>
1072
1083
 
1073
1084
  <!-- ═══ Setup ═══ -->
1074
- <div class="rp-sub" id="rp-sub-setup">
1085
+ <div class="rp-sub" id="rp-sub-setup">
1075
1086
  <!-- Add LiveKit server -->
1076
1087
  <div class="rp-form" id="rp-addserver">
1077
1088
  <h3>Add LiveKit server</h3>
@@ -1131,6 +1142,38 @@ button.act:disabled{opacity:0.5;cursor:default;}
1131
1142
 
1132
1143
  </div>
1133
1144
 
1145
+ <!-- Command sheet -->
1146
+ <div class="rp-sub" id="rp-sub-commands">
1147
+ <div class="rp-command-shell">
1148
+ <div class="rp-config-hero">
1149
+ <div><h3>RoboPark Command Sheet</h3><p>Production one-liners for the hub, LAN and Tailscale robots, health checks, recovery, Docker, LiveKit, and package operations. Edit the environment once; every generated command below updates immediately.</p></div>
1150
+ <div class="rp-config-hero-actions"><button class="rp-btn" id="rp-command-reset">Reset environment</button><button class="rp-btn primary" id="rp-command-copy-all">Copy visible commands</button></div>
1151
+ </div>
1152
+ <div class="rp-command-env">
1153
+ <div class="rp-field"><label>Hub LAN IP</label><input id="rp-command-lan" value="192.168.1.12" spellcheck="false"></div>
1154
+ <div class="rp-field"><label>Hub Tailscale IP / DNS</label><input id="rp-command-tail" value="100.84.147.24" spellcheck="false"></div>
1155
+ <div class="rp-field"><label>Site</label><input id="rp-command-site" value="Tel Aviv"></div>
1156
+ <div class="rp-field"><label>Robot name</label><input id="rp-command-robot" value="bmw" spellcheck="false"></div>
1157
+ <div class="rp-field"><label>Enrollment token</label><input id="rp-command-enrollment" placeholder="paste robot enrollment token" spellcheck="false"></div>
1158
+ <div class="rp-field"><label>Mesh token</label><input id="rp-command-token" spellcheck="false"></div>
1159
+ </div>
1160
+ <div class="rp-command-toolbar">
1161
+ <input type="search" id="rp-command-search" placeholder="Search setup, logs, camera, Docker, LiveKit, npm...">
1162
+ <select id="rp-command-category"><option value="">All categories</option><option>Provision</option><option>Health</option><option>Recovery</option><option>Media</option><option>Packages</option><option>Custom</option></select>
1163
+ <span class="rp-command-note" id="rp-command-count"></span>
1164
+ </div>
1165
+ <div class="rp-command-grid" id="rp-command-grid"></div>
1166
+ <form class="rp-command-custom" id="rp-command-custom-form">
1167
+ <h4>Add a persistent command</h4>
1168
+ <div class="rp-field"><label>Title</label><input id="rp-command-custom-title" required placeholder="Restart camera service"></div>
1169
+ <div class="rp-field"><label>Category</label><input id="rp-command-custom-category" value="Custom" placeholder="Custom"></div>
1170
+ <div class="rp-field wide"><label>Simple explanation</label><input id="rp-command-custom-description" placeholder="When to use this and what success looks like"></div>
1171
+ <div class="rp-field wide"><label>Command</label><textarea id="rp-command-custom-command" required spellcheck="false" placeholder="Paste a safe command or one-liner"></textarea></div>
1172
+ <div class="wide"><button class="rp-btn primary" type="submit">Save command to this browser</button> <span class="rp-command-note">Custom commands persist in localStorage on this operator browser.</span></div>
1173
+ </form>
1174
+ </div>
1175
+ </div>
1176
+
1134
1177
  <!-- Character + voice management -->
1135
1178
  <div class="rp-sub" id="rp-sub-config">
1136
1179
  <div class="rp-config-workspace" id="rp-templates">
@@ -1178,8 +1221,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
1178
1221
 
1179
1222
  <script>
1180
1223
  (function(){
1181
- var TOKEN = new URLSearchParams(location.search).get('token') || '';
1182
- var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
1224
+ var TOKEN = new URLSearchParams(location.search).get('token') || '';
1225
+ var QS = TOKEN ? ('?token=' + encodeURIComponent(TOKEN)) : '';
1226
+ var UI_BUILD=(document.querySelector('meta[name="robopark-ui-build"]')||{}).content||'unknown';
1227
+ fetch('/fed/ui-build'+QS,{cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(info){
1228
+ if(!info||!info.build||info.build===UI_BUILD)return;
1229
+ var key='rp-ui-reload-'+info.build;if(sessionStorage.getItem(key))return;sessionStorage.setItem(key,'1');
1230
+ var url=new URL(location.href);url.searchParams.set('ui',info.build);location.replace(url.toString());
1231
+ }).catch(function(){});
1183
1232
  var selfId = null, selected = null, lastSeq = -1, everOk = false;
1184
1233
  var rpReadOnly = false, pmBusy = false;
1185
1234
  var lastSchedulerPollAt = 0; // updated by pollRobopark() on every successful /api/telemetry fetch
@@ -1775,7 +1824,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
1775
1824
  $('pane-fleet').classList.toggle('active', name==='fleet');
1776
1825
  $('pane-park').classList.toggle('active', name==='park');
1777
1826
  $('pane-robopark').classList.toggle('active', name==='robopark');
1778
- if (location.hash.replace('#','') !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
1827
+ var currentHash=location.hash.replace('#','');
1828
+ var keepRpSubroute=name==='robopark' && currentHash.indexOf('robopark/')===0;
1829
+ if (!keepRpSubroute && currentHash !== name) { try{ history.replaceState(null,'','#'+name); }catch(e){} }
1779
1830
  if (name==='robopark'){
1780
1831
  pollRobopark();
1781
1832
  initRpSubTabs();
@@ -1786,11 +1837,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
1786
1837
  var fleetSessionsBtn=$('fleet-open-sessions'); if(fleetSessionsBtn) fleetSessionsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('live');});
1787
1838
  var fleetIncidentsBtn=$('fleet-open-incidents'); if(fleetIncidentsBtn) fleetIncidentsBtn.addEventListener('click',function(){selectTab('robopark');setRpSubTab('incidents');});
1788
1839
  var fleetRefreshBtn=$('fleet-refresh'); if(fleetRefreshBtn) fleetRefreshBtn.addEventListener('click',function(){poll();pollRobopark();});
1789
- var initHash = location.hash.replace('#','');
1790
- // Defer tab init until Park module is defined (same outer IIFE, later in source).
1791
- function initTab(){
1792
- if (initHash==='robopark'||initHash==='park') selectTab(initHash);
1793
- }
1840
+ var initHash = location.hash.replace('#','');
1841
+ // Defer tab init until Park module is defined (same outer IIFE, later in source).
1842
+ function initTab(){
1843
+ if (initHash==='robopark'||initHash.indexOf('robopark/')===0) selectTab('robopark');
1844
+ else if(initHash==='park') selectTab('park');
1845
+ }
1794
1846
  // initTab() is called at the very bottom of this IIFE, after Park is assigned.
1795
1847
 
1796
1848
  function robotsFrom(d){
@@ -2542,7 +2594,74 @@ button.act:disabled{opacity:0.5;cursor:default;}
2542
2594
  }
2543
2595
 
2544
2596
  // ── Sub-tab routing ──
2545
- var rpSubTab = 'overview';
2597
+ // Command sheet: generate production commands from one saved environment.
2598
+ var RP_COMMAND_ENV_KEY='rp-command-env-v1';
2599
+ var RP_CUSTOM_COMMANDS_KEY='rp-custom-commands-v1';
2600
+ var rpVisibleCommands=[];
2601
+ function rpCommandDefaults(){return {lan:'192.168.1.12',tail:'100.84.147.24',site:'Tel Aviv',robot:'bmw',enrollment:'',token:TOKEN};}
2602
+ function rpLoadStoredJson(key,fallback){try{var value=JSON.parse(localStorage.getItem(key)||'null');return value&&typeof value==='object'?value:fallback;}catch(e){return fallback;}}
2603
+ function rpCommandEnv(){
2604
+ var d=rpCommandDefaults(),s=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});
2605
+ function value(name){var input=$('rp-command-'+name),raw=input?input.value:(s[name]!=null?s[name]:d[name]);return String(raw==null?'':raw).trim();}
2606
+ return {lan:value('lan'),tail:value('tail'),site:value('site'),robot:value('robot'),enrollment:value('enrollment'),token:value('token')};
2607
+ }
2608
+ function rpSaveCommandEnv(){try{localStorage.setItem(RP_COMMAND_ENV_KEY,JSON.stringify(rpCommandEnv()));}catch(e){}}
2609
+ function rpShellArg(value){var text=String(value||'');return /^[A-Za-z0-9._:\/-]+$/.test(text)?text:'"'+text.replace(/"/g,'\\"')+'"';}
2610
+ function rpCommandCatalog(){
2611
+ var e=rpCommandEnv(),lan='http://'+e.lan,tail='http://'+e.tail,enroll=e.enrollment?' --enrollment-token '+rpShellArg(e.enrollment):'';
2612
+ return [
2613
+ {id:'hub-up',category:'Provision',title:'Start production hub and full dashboard',description:'Run elevated on livekit-1. Starts the mesh hub, full Park control center, scheduler proxy, LAN discovery, supervision, and updates.',command:'infinicode serve --hub --name livekit-1 --port 47913 --token '+rpShellArg(e.token)+' --lan --dashboard --scheduler-url http://127.0.0.1:8080 --auto-update --supervised'},
2614
+ {id:'robot-lan',category:'Provision',title:'Provision LAN robot',description:'One command for a Linux robot on the hub LAN. Installs the supervised mesh, RoboVision, and preview runtime.',command:'sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},
2615
+ {id:'robot-tail',category:'Provision',title:'Provision Tailscale robot',description:'One command for a Windows or Linux robot reaching the hub through Tailscale.',command:'robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+tail+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --tailscale --start --auto-start --yes'},
2616
+ {id:'operator-tail',category:'Provision',title:'Connect operator MCP over Tailscale',description:'Connects an operator workstation and coding agents to the production mesh.',command:'infinicode mcp --lan --tailscale --seed '+tail+':47913 --name dev-laptop --token '+rpShellArg(e.token)+' --mesh-port 47913'},
2617
+ {id:'hub-ui',category:'Health',title:'Open full production control center',description:'Opens the full Park dashboard through the hub Tailscale address.',command:'Start-Process "'+tail+':47913/?token='+encodeURIComponent(e.token)+'#park"'},
2618
+ {id:'hub-build',category:'Health',title:'Verify current hub UI',description:'Shows the installed version and dashboard build marker after npm update or restart.',command:'infinicode --version; Invoke-RestMethod "http://127.0.0.1:47913/fed/ui-build?token='+encodeURIComponent(e.token)+'"'},
2619
+ {id:'hub-ports',category:'Health',title:'Inspect hub ports on Windows',description:'Lists processes owning the control center, scheduler, and LiveKit ports without changing them.',command:'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalPort -in 47913,8080,7880,7881,7882,7883 } | Sort-Object LocalPort | Format-Table LocalAddress,LocalPort,OwningProcess -AutoSize'},
2620
+ {id:'scheduler-health',category:'Health',title:'Check scheduler through hub',description:'Confirms the hub proxy can reach the scheduler and return robot inventory.',command:'Invoke-RestMethod "'+tail+':47913/robopark/api/robots?token='+encodeURIComponent(e.token)+'" | ConvertTo-Json -Depth 6'},
2621
+ {id:'mesh-status',category:'Health',title:'Check mesh nodes',description:'Lists connected hub, robot, and operator nodes.',command:'Invoke-RestMethod "'+tail+':47913/fed/status?token='+encodeURIComponent(e.token)+'" | ConvertTo-Json -Depth 6'},
2622
+ {id:'pi-runtime',category:'Health',title:'Follow robot runtime logs',description:'Live systemd logs for the selected robot unified runtime.',command:'sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -f'},
2623
+ {id:'pi-ports',category:'Health',title:'Inspect robot service ports',description:'Checks mesh, camera, motion webhook, and audio listeners on a Linux robot.',command:"sudo ss -ltnp | grep -E ':(47913|5000|5057|8000)'"},
2624
+ {id:'camera-inventory',category:'Media',title:'Read RoboVision media inventory',description:'Returns the real cameras, microphones, and speakers detected by RoboVision.',command:'curl -s http://127.0.0.1:5000/api/media/inventory'},
2625
+ {id:'audio-inventory',category:'Media',title:'Read RoboVision audio devices',description:'Returns the microphone and speaker inventory used by the robot audio server.',command:'curl -s http://127.0.0.1:8000/devices'},
2626
+ {id:'camera-devices',category:'Media',title:'List valid Linux capture devices',description:'Shows V4L2 devices and capabilities so metadata-only nodes are not selected as cameras.',command:'v4l2-ctl --list-devices; for d in /dev/video*; do echo "--- $d"; v4l2-ctl -d "$d" --all 2>/dev/null | grep -E "Card type|Video Capture|Device Caps"; done'},
2627
+ {id:'camera-preview',category:'Media',title:'Open direct RoboVision camera feed',description:'Opens the robot MJPEG feed locally for camera-only diagnosis.',command:'xdg-open http://127.0.0.1:5000/video_feed'},
2628
+ {id:'robot-restart',category:'Recovery',title:'Clean restart selected robot',description:'Stops stale supervised processes, then recreates the complete LAN runtime.',command:'sudo robopark stop && sudo robopark setup --robot --name '+rpShellArg(e.robot)+' --site '+rpShellArg(e.site)+' --hub-url '+lan+':47913 --token '+rpShellArg(e.token)+' --scheduler-port 8080'+enroll+' --port 47913 --lan --start --auto-start --yes'},
2629
+ {id:'robot-status',category:'Recovery',title:'Inspect robot service failure',description:'Shows service state and the latest 150 log lines after a failed start.',command:'sudo systemctl status robopark-robot-runtime-'+e.robot+'.service --no-pager; sudo journalctl -u robopark-robot-runtime-'+e.robot+'.service -n 150 --no-pager'},
2630
+ {id:'docker-health',category:'Health',title:'Inspect Docker and LiveKit containers',description:'Shows container state, health, ports, and names on the hub.',command:'docker ps -a --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Image}}"'},
2631
+ {id:'docker-restart',category:'Recovery',title:'Restart Docker Desktop service',description:'Run from elevated PowerShell only when Docker Desktop is stuck starting.',command:'Restart-Service com.docker.service; Start-Process -FilePath "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe"'},
2632
+ {id:'tailscale-status',category:'Health',title:'Check Tailscale routes and Serve',description:'Confirms the node is connected and shows any active Serve proxy.',command:'tailscale status; tailscale serve status'},
2633
+ {id:'npm-version',category:'Packages',title:'Check installed and published versions',description:'Compares this device with the npm latest tag.',command:'infinicode --version; npm view infinicode version'},
2634
+ {id:'npm-update',category:'Packages',title:'Update InfiniCode and RoboPark',description:'Installs the current published production package globally.',command:'npm install -g infinicode@latest --force'},
2635
+ {id:'npm-cache',category:'Packages',title:'Repair npm metadata cache',description:'Use when a new version returns ETARGET. Verify metadata before clearing anything.',command:'npm cache verify; npm view infinicode dist-tags --json'}
2636
+ ];
2637
+ }
2638
+ function rpCustomCommands(){var list=rpLoadStoredJson(RP_CUSTOM_COMMANDS_KEY,[]);return Array.isArray(list)?list:[];}
2639
+ function rpSaveCustomCommands(list){try{localStorage.setItem(RP_CUSTOM_COMMANDS_KEY,JSON.stringify(list));}catch(e){showOut('Could not save custom command: '+e.message);}}
2640
+ function rpLegacyCopy(value){var area=document.createElement('textarea');area.value=value;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();try{document.execCommand('copy');showOut('command copied');}catch(e){showOut('Copy failed: select the command manually');}area.remove();}
2641
+ function rpCopyCommand(text){var value=String(text||'');if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(value).then(function(){showOut('command copied');}).catch(function(){rpLegacyCopy(value);});}else rpLegacyCopy(value);}
2642
+ function renderCommandSheet(){
2643
+ var grid=$('rp-command-grid');if(!grid)return;
2644
+ var search=String(($('rp-command-search')||{}).value||'').toLowerCase().trim(),category=String(($('rp-command-category')||{}).value||'').toLowerCase();
2645
+ var custom=rpCustomCommands().map(function(x){return {id:String(x.id),category:String(x.category||'Custom'),title:String(x.title||'Custom command'),description:String(x.description||''),command:String(x.command||''),custom:true};});
2646
+ rpVisibleCommands=rpCommandCatalog().concat(custom).filter(function(x){if(category&&x.category.toLowerCase()!==category)return false;return !search||(x.title+' '+x.category+' '+x.description+' '+x.command).toLowerCase().indexOf(search)>=0;});
2647
+ var count=$('rp-command-count');if(count)count.textContent=rpVisibleCommands.length+' command'+(rpVisibleCommands.length===1?'':'s');
2648
+ if(!rpVisibleCommands.length){grid.innerHTML='<div class="rp-command-empty">No commands match this filter.</div>';return;}
2649
+ grid.innerHTML=rpVisibleCommands.map(function(x){return '<article class="rp-command-card"><div class="rp-command-head"><b>'+esc(x.title)+'</b><span class="rp-command-tag">'+esc(x.category)+'</span></div><p>'+esc(x.description)+'</p><pre class="rp-command-code"><code>'+esc(x.command)+'</code></pre><div class="rp-command-actions"><button class="rp-btn primary" data-command-copy="'+esc(x.id)+'">Copy command</button>'+(x.custom?'<button class="rp-btn danger" data-command-delete="'+esc(x.id)+'">Delete</button>':'')+'</div></article>';}).join('');
2650
+ }
2651
+ function initCommandSheet(){
2652
+ var fields=['lan','tail','site','robot','enrollment','token'],defaults=rpCommandDefaults(),saved=rpLoadStoredJson(RP_COMMAND_ENV_KEY,{});
2653
+ fields.forEach(function(name){var input=$('rp-command-'+name);if(!input)return;input.value=saved[name]!=null?saved[name]:defaults[name];input.addEventListener('input',function(){rpSaveCommandEnv();renderCommandSheet();});});
2654
+ var search=$('rp-command-search'),category=$('rp-command-category'),grid=$('rp-command-grid'),reset=$('rp-command-reset'),copyAll=$('rp-command-copy-all'),form=$('rp-command-custom-form');
2655
+ if(search)search.addEventListener('input',renderCommandSheet);if(category)category.addEventListener('change',renderCommandSheet);
2656
+ if(reset)reset.addEventListener('click',function(){try{localStorage.removeItem(RP_COMMAND_ENV_KEY);}catch(e){}fields.forEach(function(name){var input=$('rp-command-'+name);if(input)input.value=defaults[name];});renderCommandSheet();showOut('command environment reset');});
2657
+ if(copyAll)copyAll.addEventListener('click',function(){rpCopyCommand(rpVisibleCommands.map(function(x){return '# '+x.title+'\\n'+x.command;}).join('\\n\\n'));});
2658
+ if(grid)grid.addEventListener('click',function(ev){var copy=ev.target.closest('[data-command-copy]'),del=ev.target.closest('[data-command-delete]');if(copy){var id=copy.getAttribute('data-command-copy'),item=rpVisibleCommands.find(function(x){return x.id===id;});if(item)rpCopyCommand(item.command);}if(del){var removeId=del.getAttribute('data-command-delete');rpSaveCustomCommands(rpCustomCommands().filter(function(x){return String(x.id)!==removeId;}));renderCommandSheet();showOut('custom command deleted');}});
2659
+ if(form)form.addEventListener('submit',function(ev){ev.preventDefault();var title=$('rp-command-custom-title').value.trim(),command=$('rp-command-custom-command').value.trim();if(!title||!command)return;var list=rpCustomCommands();list.push({id:'custom-'+Date.now(),title:title,category:$('rp-command-custom-category').value.trim()||'Custom',description:$('rp-command-custom-description').value.trim(),command:command});rpSaveCustomCommands(list);$('rp-command-custom-title').value='';$('rp-command-custom-description').value='';$('rp-command-custom-command').value='';renderCommandSheet();showOut('custom command saved in this browser');});
2660
+ renderCommandSheet();
2661
+ }
2662
+ initCommandSheet();
2663
+
2664
+ var rpSubTab = 'overview';
2546
2665
  var rpIntervals = {}; // sub-tab name -> setInterval handle
2547
2666
  var rpHistoryAll = []; // full recent-session list (we paginate client-side)
2548
2667
  function setRpSubTab(name){
@@ -2561,7 +2680,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2561
2680
  s.classList.toggle('active', s.id==='rp-sub-'+name);
2562
2681
  }
2563
2682
  // stop every sub-tab interval, then start only the active one
2564
- var names = ['overview','incidents','live','voice','history','audit','fleet','config','setup'];
2683
+ var names = ['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'];
2565
2684
  for(var k=0;k<names.length;k++){
2566
2685
  var n=names[k];
2567
2686
  if(rpIntervals[n]){ clearInterval(rpIntervals[n]); rpIntervals[n]=null; }
@@ -2572,7 +2691,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
2572
2691
  if(name==='voice'){ refreshVoiceCallOptions(); }
2573
2692
  if(name==='history'){ rpHistoryPage=0; pollHistory(); }
2574
2693
  if(name==='audit'){ pollAuditLog(); rpIntervals.audit = setInterval(pollAuditLog, 6000); }
2575
- if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }
2694
+ if(name==='fleet'){ pollFleet(); rpIntervals.fleet = setInterval(pollFleet, 5000); }
2695
+ if(name==='commands'){ renderCommandSheet(); }
2576
2696
  if(name==='setup'){ pollSettings(); loadCharacterPresets(); loadVoiceStacks(); pollRuntimeHealth(); }
2577
2697
  // remember last sub-tab in URL hash
2578
2698
  try{ history.replaceState(null,'','#robopark/'+name); }catch(e){}
@@ -2588,7 +2708,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
2588
2708
  var h=String(location.hash||'').replace('#','');
2589
2709
  var m = h.match(/^robopark\\/(.+)$/);
2590
2710
  var initial = (m && m[1]) ? m[1] : 'overview';
2591
- if(['overview','incidents','live','voice','history','audit','fleet','config','setup'].indexOf(initial)<0) initial='overview';
2711
+ if(['overview','incidents','live','voice','history','audit','fleet','config','commands','setup'].indexOf(initial)<0) initial='overview';
2592
2712
  setRpSubTab(initial);
2593
2713
  }
2594
2714
 
@@ -3862,7 +3982,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
3862
3982
  var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;
3863
3983
  var previewOn=false, previewTimer=null; // on-demand operator camera preview
3864
3984
  var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
3865
- var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[];
3985
+ var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[], micMonitorConnecting=false, micMonitorState='no active conversation session', micMonitorAudible=false, micMonitorVolume=0.8;
3866
3986
  var imgs={}; // preloaded robot concept avatars
3867
3987
  var TW=44, TH=22, PARK_W=15, PARK_D=8;
3868
3988
  // Plaza footprint and parking lot (upper-right corner of the plaza)
@@ -5951,8 +6071,10 @@ button.act:disabled{opacity:0.5;cursor:default;}
5951
6071
  if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
5952
6072
  if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
5953
6073
  if(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }
5954
- if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
5955
- micAnalyser=null;
6074
+ if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
6075
+ lkAudioEls.forEach(function(el){try{el.pause();el.remove();}catch(e){}});lkAudioEls=[];
6076
+ micMonitorAudible=false;micMonitorConnecting=false;micMonitorState='no active conversation session';
6077
+ micAnalyser=null;
5956
6078
  }
5957
6079
 
5958
6080
  // ── setup flow for unjoined park pads ──
@@ -6132,9 +6254,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
6132
6254
  }
6133
6255
  fill('pk-video-device', video, device.video_device||'auto');
6134
6256
  fill('pk-audio-input', input, device.audio_device||'default');
6135
- fill('pk-audio-output', output, device.audio_output_device||'default');
6136
- var greetings=$('pk-greeting-phrases');
6137
- if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
6257
+ fill('pk-audio-output', output, device.audio_output_device||'default');
6258
+ var selectedOutput=$('pk-audio-output'),selectedLabel=$('pk-spk-selected');
6259
+ if(selectedLabel&&selectedOutput){var selectedOption=selectedOutput.options&&selectedOutput.selectedIndex>=0?selectedOutput.options[selectedOutput.selectedIndex]:null;selectedLabel.textContent=selectedOption?String(selectedOption.textContent||selectedOption.value):String(selectedOutput.value||'default');}
6260
+ var greetings=$('pk-greeting-phrases');
6261
+ if(greetings && document.activeElement!==greetings) greetings.value=(device.greeting_phrases||[]).join(String.fromCharCode(10));
6262
+ var testPhrase=$('pk-spk-phrase');if(testPhrase&&document.activeElement!==testPhrase&&device.greeting_phrases&&device.greeting_phrases.length)testPhrase.value=device.greeting_phrases[0];
6138
6263
  var note=$('pk-device-note');
6139
6264
  if(note){
6140
6265
  if(!device.id && n && n.connected){
@@ -6522,13 +6647,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6522
6647
  + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
6523
6648
  + '<div class="pk-note">Starts the camera, sends the same motion trigger used in production, and follows every reported stage remotely.</div>'
6524
6649
  + '<div class="pk-pipeline" id="pk-pipeline"></div>')
6525
- + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
6650
+ + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div><div class="pk-ctl" style="margin-top:.5rem"><button class="rp-btn" id="pk-mic-listen">Listen in browser</button><label class="pk-note" style="display:flex;align-items:center;gap:.35rem;margin:0">volume <input id="pk-mic-volume" type="range" min="0" max="100" value="80" style="width:90px"></label></div><div class="pk-note" id="pk-mic-listen-note">Muted by default. Use headphones to prevent acoustic feedback.</div>')
6526
6651
  + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">TTS plays on the robot speaker — use “Test speaker” below to round-trip a tone and confirm the audio path</div>'
6527
- + '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
6528
- + '<div class="rp-field"><label>Frequency</label><select id="pk-spk-freq"><option value="500">500 Hz (low)</option><option value="1000" selected>1000 Hz (1 kHz reference)</option><option value="2000">2000 Hz (presence)</option><option value="3000">3000 Hz (clarity)</option></select></div>'
6652
+ + '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
6653
+ + '<div class="rp-field"><label>Test type</label><select id="pk-spk-mode"><option value="tts" selected>Cached character voice</option><option value="tone">Tone + mic round-trip</option></select></div>'
6654
+ + '<div class="rp-field"><label>Selected output</label><div class="pk-note" id="pk-spk-selected" style="margin:.45rem 0 0">loading device...</div></div>'
6655
+ + '<div class="rp-field" style="grid-column:1/-1"><label>Voice test phrase</label><input id="pk-spk-phrase" value="Hello, this is a RoboPark speaker test." maxlength="240"></div>'
6656
+ + '<div class="rp-field"><label>Frequency</label><select id="pk-spk-freq"><option value="500">500 Hz (low)</option><option value="1000" selected>1000 Hz (1 kHz reference)</option><option value="2000">2000 Hz (presence)</option><option value="3000">3000 Hz (clarity)</option></select></div>'
6529
6657
  + '<div class="rp-field"><label>Duration</label><select id="pk-spk-dur"><option value="0.4">0.4 s</option><option value="0.6" selected>0.6 s</option><option value="1.0">1.0 s</option></select></div>'
6530
6658
  + '<div style="grid-column:1/-1;display:flex;gap:0.4rem;align-items:center;">'
6531
- + '<button class="rp-btn primary" id="pk-spk-runtest" data-robot="'+esc(schedId(n))+'">▶ Test speaker</button>'
6659
+ + '<button class="rp-btn primary" id="pk-spk-runtest" data-robot="'+esc(schedId(n))+'">Play cached voice</button>'
6660
+ + '<button class="rp-btn" id="pk-spk-tone">Quick tone</button>'
6532
6661
  + '<span class="pk-note" id="pk-spk-test-stateline" style="margin:0">awaiting test</span>'
6533
6662
  + '</div>'
6534
6663
  + '<div style="grid-column:1/-1;" id="pk-spk-test-result"></div>'
@@ -6540,7 +6669,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6540
6669
  + sect('🩺 robot services','<div id="pk-svc-list" class="pk-svc-list"><div class="pk-note">waiting for supervisor report…</div></div><div class="pk-note" id="pk-svc-age" style="margin-top:0.35rem;"></div><div class="rp-field" style="margin-top:0.55rem;"><label>Quick run (on the robot, from packages/robopark/scheduler)</label><input id="pk-svc-clicmd" readonly value="python robot_supervisor.py"></div><button class="rp-btn" id="pk-svc-clicmd-copy" style="margin-top:0.35rem;">📋 Copy command</button>')
6541
6670
  + buildShellSection(schedId(n))
6542
6671
  + sect('🎭 character / voice','<div class="pk-note" id="pk-eff-note" style="margin-bottom:0.45rem;">loading effective config…</div><div class="rp-field"><label>Character preset</label><select class="rp-char" id="pk-eff-char" data-robot="'+esc(schedId(n))+'">'+effPresetOptions+'</select></div><div class="rp-field"><label>Voice stack</label><select class="rp-voice" id="pk-eff-voice" data-robot="'+esc(schedId(n))+'">'+effVoiceOptions+'</select></div><div class="rp-config-actions" style="margin-top:.45rem"><button class="rp-btn primary" id="pk-eff-save" data-robot="'+esc(schedId(n))+'">Apply to robot</button><button class="rp-btn" id="pk-edit-character">Edit character</button><button class="rp-btn" id="pk-edit-stack">Edit stack</button><button class="rp-btn" id="pk-new-stack">New stack</button></div>');
6543
- b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
6672
+ b.innerHTML += sect('Camera + audio devices','<div class="rp-field"><label>Search devices</label><input id="pk-device-search" placeholder="camera, USB, microphone, HDMI..." autocomplete="off"></div><div class="rp-field"><label>Camera input</label><select id="pk-video-device"></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input"></select></div><div class="rp-field"><label>Speaker / output inventory</label><select id="pk-audio-output"></select></div><button class="rp-btn primary" id="pk-device-test-speaker" style="margin-top:0.35rem">Test selected speaker now</button><div class="rp-field"><label>Preset greeting phrases <span class="pk-note">one per line, cached per voice</span></label><textarea id="pk-greeting-phrases" rows="3" placeholder="Hi, I am Jaguar! / Welcome to RoboPark."></textarea></div><button class="rp-btn" id="pk-device-save" style="margin-top:0.35rem">Save device selection + greetings</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
6544
6673
  // Put live hardware selection beside the camera and audio tests,
6545
6674
  // ahead of persona and service administration in the Park drawer.
6546
6675
  var deviceInput=$('pk-device-search'), deviceSection=deviceInput&&deviceInput.closest('.pk-sect');
@@ -6549,11 +6678,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6549
6678
  refreshDeviceSelectors(n);
6550
6679
  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>';
6551
6680
  var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
6552
- var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
6681
+ 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); });
6683
+ var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});
6684
+ 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;});});
6553
6685
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
6554
6686
  _bindShellSection(n);
6555
6687
  _spkTestRenderHistory(schedId(n));
6556
- var runSpkTest = $('pk-spk-runtest'); if(runSpkTest) runSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n); });
6688
+ var runSpkTest = $('pk-spk-runtest'); if(runSpkTest) runSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n); });
6689
+ var runSpkTone = $('pk-spk-tone'); if(runSpkTone) runSpkTone.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tone'); });
6690
+ var quickSpkTest = $('pk-device-test-speaker'); if(quickSpkTest) quickSpkTest.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runSpeakerTest(n,'tts'); });
6691
+ var spkMode = $('pk-spk-mode'); if(spkMode) spkMode.addEventListener('change', function(){ if(runSpkTest) runSpkTest.textContent=spkMode.value==='tts'?'Play cached voice':'Run tone test'; });
6557
6692
  var gl=$('pk-golive');
6558
6693
  if(gl){
6559
6694
  gl.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } goLive(n); });
@@ -6716,6 +6851,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6716
6851
  function startCam(n){
6717
6852
  idleCam('connecting');
6718
6853
  tryVisionOverlay(n, true);
6854
+ startMicMonitor(n);
6719
6855
  return;
6720
6856
  var note=$('pk-camnote');
6721
6857
  camAbort = ('AbortController' in window)?new AbortController():null;
@@ -6748,17 +6884,51 @@ button.act:disabled{opacity:0.5;cursor:default;}
6748
6884
  var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
6749
6885
  var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
6750
6886
  }
6751
- function attachLiveKit(info, n){
6887
+ function selectedRobotMic(){var el=$('pk-audio-input');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}
6888
+ function selectedRobotOutput(){var el=$('pk-audio-output');if(!el)return 'default';var option=el.options&&el.selectedIndex>=0?el.options[el.selectedIndex]:null;return option?String(option.textContent||option.value):String(el.value||'default');}
6889
+ function setMicMonitorAudible(enabled){
6890
+ micMonitorAudible=!!enabled;
6891
+ var btn=$('pk-mic-listen'),note=$('pk-mic-listen-note');
6892
+ if(btn){btn.textContent=micMonitorAudible?'Mute browser monitor':'Listen in browser';btn.classList.toggle('primary',micMonitorAudible);}
6893
+ 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.';
6894
+ lkAudioEls.forEach(function(el){
6895
+ el.muted=!micMonitorAudible;el.volume=micMonitorVolume;
6896
+ if(micMonitorAudible)el.play().catch(function(e){if(note)note.textContent='Browser blocked playback: '+e.message+' — press Listen again.';});
6897
+ });
6898
+ }
6899
+ function startMicMonitor(n){
6900
+ if(lkRoom||micMonitorConnecting)return;
6901
+ micMonitorConnecting=true;
6902
+ micMonitorState='checking active conversation session';
6903
+ var state=$('pk-micstate');if(state)state.textContent='selected '+selectedRobotMic()+' · checking active session...';
6904
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/stream'+QS,{cache:'no-store'})
6905
+ .then(function(r){if(!r.ok)throw new Error('scheduler '+r.status);return r.json();})
6906
+ .then(function(info){
6907
+ micMonitorConnecting=false;
6908
+ if(info&&info.active&&info.url&&info.token&&window.LivekitClient){micMonitorState='connecting to '+info.url;attachLiveKit(info,n,true);return;}
6909
+ micMonitorState=info&&info.reason?String(info.reason).replace(/_/g,' '):'waiting for conversation session';
6910
+ if(state)state.textContent='selected '+selectedRobotMic()+' · waiting for conversation session';
6911
+ })
6912
+ .catch(function(e){micMonitorConnecting=false;micMonitorState='monitor unavailable: '+e.message;if(state)state.textContent='selected '+selectedRobotMic()+' · '+micMonitorState;});
6913
+ }
6914
+ function attachLiveKit(info, n, audioOnly){
6752
6915
  var note=$('pk-camnote'), res=$('pk-camres');
6753
6916
  try{
6754
6917
  var LK=window.LivekitClient; lkRoom=new LK.Room();
6755
6918
  lkRoom.on(LK.RoomEvent.TrackSubscribed, function(track){
6756
- if(track.kind==='video'){
6757
- var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
6919
+ if(track.kind==='video'){
6920
+ if(audioOnly)return;
6921
+ var el=track.attach(); el.setAttribute('playsinline',''); el.muted=true; el.autoplay=true;
6758
6922
  var cv=$('pk-camcv'); if(cv&&cv.parentNode) cv.parentNode.replaceChild(el, cv);
6759
6923
  if(res) res.textContent='LIVE'; if(note) note.textContent='live camera via LiveKit · '+esc(info.room);
6760
- } else if(track.kind==='audio'){
6761
- hookMicAnalyser(track);
6924
+ } else if(track.kind==='audio'){
6925
+ // Listen only to the robot's published microphone. Agent TTS may
6926
+ // also be present in the room, but hearing that directly would
6927
+ // bypass the physical speaker -> microphone round-trip test.
6928
+ if(track.name&&track.name!=='microphone')return;
6929
+ hookMicAnalyser(track);
6930
+ micMonitorState='live track published';
6931
+ var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · live track published';
6762
6932
  // Muted by default: this is the camera-preview panel, running on
6763
6933
  // the SAME physical device as the robot in local testing. Auto-
6764
6934
  // playing the subscribed track (robot mic, or later the agent's
@@ -6767,16 +6937,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6767
6937
  // that drowns out the actual conversation. The VU meter above
6768
6938
  // (hookMicAnalyser) taps the raw track directly and works fine
6769
6939
  // muted.
6770
- var audio=track.attach(); audio.autoplay=true; audio.controls=false; audio.muted=true; audio.style.display='none'; document.body.appendChild(audio); lkAudioEls.push(audio);
6771
- audio.play().catch(function(){});
6940
+ 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);
6941
+ var listenNote=$('pk-mic-listen-note');if(listenNote&&micMonitorAudible)listenNote.textContent='Robot mic is playing in this browser. Use headphones.';
6942
+ audio.play().catch(function(){});
6772
6943
  }
6773
6944
  });
6774
- lkRoom.on(LK.RoomEvent.Disconnected, function(){ micAnalyser=null; if(note) note.textContent='session ended'; if(res) res.textContent='—'; });
6945
+ 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='—';});
6775
6946
  var livekitUrl=info.url;
6776
6947
  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');
6777
- lkRoom.connect(livekitUrl, info.token).then(function(){ if(note) note.textContent='connected · waiting for camera track…'; })
6778
- .catch(function(e){ idleCam('unreachable'); if(note) note.textContent='LiveKit connection failed'; if(window.console) console.error('RoboPark LiveKit connection failed',e); });
6779
- }catch(e){ idleCam('no-sdk'); }
6948
+ 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…';})
6949
+ .catch(function(e){lkRoom=null;micMonitorConnecting=false;micMonitorState='LiveKit connection failed: '+(e&&e.message?e.message:'unreachable');var micState=$('pk-micstate');if(micState)micState.textContent='selected '+selectedRobotMic()+' · '+micMonitorState;if(!audioOnly){idleCam('unreachable');if(note)note.textContent='LiveKit connection failed';}if(window.console)console.error('RoboPark LiveKit connection failed',e);});
6950
+ }catch(e){ micMonitorConnecting=false;micMonitorState='LiveKit SDK unavailable';idleCam('no-sdk'); }
6780
6951
  }
6781
6952
  // Real mic level: tap the robot's subscribed 'mic' audio track with a Web
6782
6953
  // Audio analyser and read RMS in startMeters (no invented levels).
@@ -6817,7 +6988,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6817
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); }
6818
6989
  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)'; }
6819
6990
  set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
6820
- set('pk-micstate', micAnalyser?(lvl>0.12?'live · hearing audio':'live · quiet'):'no live mic track');
6991
+ set('pk-micstate', micAnalyser?('selected '+selectedRobotMic()+' · '+(lvl>0.12?'live, hearing audio':'live, quiet')):('selected '+selectedRobotMic()+' · '+micMonitorState));
6821
6992
  // SPEAKER — the Pi plays TTS out its own speaker; we don't get a
6822
6993
  // published track, so the meter stays at "—" unless a speaker
6823
6994
  // round-trip test is currently running. While a test plays, the
@@ -6893,7 +7064,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6893
7064
  var mark = t.pass===true ? '✓' : (t.pass===false ? '✗' : '·');
6894
7065
  var when = new Date(t.t).toTimeString().slice(0,8);
6895
7066
  var peak = t.peak_db != null ? (t.peak_db+' dB') : '—';
6896
- var freq = t.frequency != null ? (Math.round(t.frequency)+' Hz') : '—';
7067
+ var freq = t.mode==='tts' ? 'cached voice' : (t.frequency != null ? (Math.round(t.frequency)+' Hz') : '—');
6897
7068
  return '<div class="pk-spk-hist-row"><span class="pk-spk-mark" style="color:'+cls+'">'+mark+'</span>'
6898
7069
  + '<span class="pk-spk-when">'+when+'</span>'
6899
7070
  + '<span class="pk-spk-freq">'+freq+'</span>'
@@ -6916,14 +7087,15 @@ button.act:disabled{opacity:0.5;cursor:default;}
6916
7087
  + '</div>';
6917
7088
  return;
6918
7089
  }
6919
- var banner = result.pass
6920
- ? '<div class="pk-spk-banner ok">✓ speaker round-trip OK</div>'
7090
+ var isTts=result.mode==='tts';
7091
+ var banner = result.pass
7092
+ ? '<div class="pk-spk-banner ok">✓ '+(isTts?'cached voice playback OK':'speaker round-trip OK')+'</div>'
6921
7093
  : '<div class="pk-spk-banner bad">✗ no signal detected</div>';
6922
7094
  var meta = '<div class="pk-spk-meta">'
6923
7095
  + '<div><span class="l">peak</span><span class="v">'+esc(result.recorded_peak_db+' dB')+'</span></div>'
6924
7096
  + '<div><span class="l">RMS</span><span class="v">'+esc(result.recorded_rms_db+' dB')+'</span></div>'
6925
7097
  + '<div><span class="l">threshold</span><span class="v">'+esc(result.threshold_db+' dB')+'</span></div>'
6926
- + '<div><span class="l">frequency</span><span class="v">'+esc(Math.round(result.frequency)+' Hz')+'</span></div>'
7098
+ + (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>')
6927
7099
  + '<div><span class="l">duration</span><span class="v">'+esc(result.duration+' s')+'</span></div>'
6928
7100
  + '<div><span class="l">latency</span><span class="v">'+esc(result.duration_ms+' ms')+'</span></div>'
6929
7101
  + '<div><span class="l">output</span><span class="v">'+esc(result.output_device)+'</span></div>'
@@ -6933,29 +7105,34 @@ button.act:disabled{opacity:0.5;cursor:default;}
6933
7105
  host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
6934
7106
  + banner + meta + diag + '</div>';
6935
7107
  }
6936
- function runSpeakerTest(n){
6937
- var robotId = schedId(n);
6938
- var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = true; btn.textContent = 'testing…'; }
7108
+ function runSpeakerTest(n, forcedMode){
7109
+ var robotId = schedId(n), mode=forcedMode||(($('pk-spk-mode')||{}).value||'tts');
7110
+ var buttons=[$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')];buttons.forEach(function(b){if(b)b.disabled=true;});
7111
+ var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = true; btn.textContent = mode==='tts'?'loading voice…':'testing…'; }
6939
7112
  var status = $('pk-spk-test-stateline');
6940
- if(status) status.textContent = 'queued — waiting for robot…';
6941
- var freq = parseFloat(($('pk-spk-freq')||{}).value || '1000');
6942
- var dur = parseFloat(($('pk-spk-dur')||{}).value || '0.6');
7113
+ if(status) status.textContent = mode==='tts'?'loading cached character voice…':'queued — waiting for robot…';
7114
+ var freq = parseFloat(($('pk-spk-freq')||{}).value || '1000');
7115
+ var dur = parseFloat(($('pk-spk-dur')||{}).value || '0.6');
7116
+ var phrase=String((($('pk-spk-phrase')||{}).value)||'Hello, I am ready to talk.').trim();
7117
+ var output=(($('pk-audio-output')||{}).value)||'default';
7118
+ var input=(($('pk-audio-input')||{}).value)||'default';
6943
7119
  // Animate the meter with a synthetic "during-test" peak so the
6944
7120
  // operator sees something happen immediately while waiting for
6945
7121
  // the real recording.
6946
7122
  _spkTestSetLive(robotId, {live:true, rms: 0.45, pass: undefined, waveform: null});
6947
7123
  rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',
6948
- {frequency: freq, duration: dur, amplitude: 0.6, threshold_db: -30})
7124
+ {mode:mode, text:phrase, frequency:freq, duration:dur, amplitude:0.6, threshold_db:-30, output:output, input:input})
6949
7125
  .then(function(q){
6950
7126
  // long-poll up to 8s, then fall back to 12s of result polling
6951
- if(status) status.textContent = 'playing tone + recording…';
7127
+ if(status) status.textContent = mode==='tts'?'playing cached voice on '+selectedRobotOutput()+'…':'playing tone + recording…';
6952
7128
  return _shellWaitForResult(robotId, q.request_id, q.device_id || robotId, '', 'speaker-test');
6953
7129
  })
6954
7130
  .catch(function(e){
6955
7131
  if(status) status.textContent = '✗ test failed: '+e.message;
6956
- _spkTestSetLive(robotId, {live:false});
6957
- _spkTestRenderResult(robotId, {ok:false, error:e.message});
6958
- if(btn){ btn.disabled = false; btn.textContent = '▶ Test speaker'; }
7132
+ _spkTestSetLive(robotId, {live:false});
7133
+ _spkTestRenderResult(robotId, {ok:false, error:e.message});
7134
+ buttons.forEach(function(b){if(b)b.disabled=false;});
7135
+ if(btn){ btn.disabled = false; btn.textContent = mode==='tts'?'Play cached voice':'Run tone test'; }
6959
7136
  });
6960
7137
  }
6961
7138
  // Render a synthetic "during-test" waveform so the meter animates
@@ -6968,7 +7145,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
6968
7145
  t: Date.now(),
6969
7146
  pass: result && result.ok ? (result.pass===true) : null,
6970
7147
  peak_db: result && result.recorded_peak_db != null ? result.recorded_peak_db : null,
6971
- frequency: result && result.frequency != null ? result.frequency : null,
7148
+ frequency: result && result.frequency != null ? result.frequency : null,
7149
+ mode: result && result.mode || 'tone',
6972
7150
  diagnostic: result && result.diagnostic || (result && !result.ok ? result.error : null),
6973
7151
  });
6974
7152
  while(list.length > 5) list.pop();
@@ -6983,15 +7161,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
6983
7161
  _origRenderShellResult(robotId, result, label);
6984
7162
  if(label === 'speaker-test'){
6985
7163
  _spkTestSetLive(robotId, {live:false});
6986
- _spkTestMark(robotId, result);
6987
- _spkTestRenderResult(robotId, result);
7164
+ _spkTestMark(robotId, result);
7165
+ _spkTestRenderResult(robotId, result);
7166
+ [$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')].forEach(function(b){if(b)b.disabled=false;});
6988
7167
  var status = $('pk-spk-test-stateline');
6989
7168
  if(status){
6990
7169
  if(!result || !result.ok){ status.textContent = '✗ test failed'; }
6991
7170
  else if(result.pass){ status.textContent = '✓ pass · '+result.recorded_peak_db+' dB peak'; }
6992
7171
  else { status.textContent = '✗ no signal — '+result.recorded_peak_db+' dB peak'; }
6993
7172
  }
6994
- var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = ' Test speaker'; }
7173
+ var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }
6995
7174
  }
6996
7175
  };
6997
7176