infinicode 2.8.96 → 2.8.98

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.
@@ -362,18 +362,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
362
362
  .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
363
363
  .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
364
364
  .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
365
- .pk-pipeline{display:grid;gap:0.32rem;margin-top:0.6rem;}
366
- .pk-pipe-step{display:grid;grid-template-columns:0.55rem 8.2rem 1fr;gap:0.45rem;align-items:start;padding:0.32rem 0.4rem;border-radius:7px;background:rgba(255,255,255,0.025);font-family:var(--mono);font-size:0.56rem;color:var(--muted);}
367
- .pk-pipe-step i{width:0.48rem;height:0.48rem;border-radius:50%;margin-top:0.08rem;background:var(--muted);box-shadow:0 0 0 2px rgba(255,255,255,0.04);}
368
- .pk-pipe-step.running i{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite;}
369
- .pk-pipe-step.ok i{background:var(--ok);box-shadow:0 0 7px rgba(70,220,150,.45);}
370
- .pk-pipe-step.failed i,.pk-pipe-step.blocked i{background:var(--bad);box-shadow:0 0 7px rgba(255,90,90,.45);}
371
- .pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.04em;}
372
- .pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;}
373
- .pk-pipeline-summary{padding:0.5rem 0.6rem;border:1px solid var(--glass-border);border-radius:8px;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
365
+ .pk-pipeline{display:grid;gap:0.42rem;margin-top:0.7rem;position:relative;}
366
+ .pk-pipe-step{--phase:var(--gold);display:grid;grid-template-columns:2rem minmax(7.2rem,.72fr) minmax(9rem,1.35fr);gap:0.55rem;align-items:center;padding:0.55rem 0.62rem;border-radius:11px;border:1px solid rgba(255,255,255,.06);background:linear-gradient(110deg,color-mix(in srgb,var(--phase) 7%,transparent),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.58rem;color:var(--muted);transition:transform .18s,border-color .18s,background .18s;position:relative;overflow:hidden;}
367
+ .pk-pipe-step.phase-hardware{--phase:#63d5c6}.pk-pipe-step.phase-trigger{--phase:#efb94c}.pk-pipe-step.phase-session{--phase:#66a8ff}.pk-pipe-step.phase-media{--phase:#68d48f}.pk-pipe-step.phase-intelligence{--phase:#e38bff}.pk-pipe-step.phase-playback{--phase:#ff9b62}.pk-pipe-step.phase-close{--phase:#a8b0bd}
368
+ .pk-pipe-step .step-index{display:grid;place-items:center;width:1.65rem;height:1.65rem;border-radius:8px;border:1px solid color-mix(in srgb,var(--phase) 35%,transparent);color:var(--phase);background:color-mix(in srgb,var(--phase) 10%,transparent);font-weight:700;}
369
+ .pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.055em;font-weight:650;display:block;}.pk-pipe-step .phase{display:block;color:var(--phase);font-size:.49rem;letter-spacing:.08em;margin-top:.14rem;}
370
+ .pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;line-height:1.35;}.pk-pipe-step .time{display:block;color:color-mix(in srgb,var(--muted) 75%,transparent);font-size:.5rem;margin-top:.12rem;}
371
+ .pk-pipe-step.ok{border-color:color-mix(in srgb,var(--ok) 22%,transparent)}
372
+ .pk-pipe-step.failed,.pk-pipe-step.blocked{border-color:rgba(255,90,90,.48);background:linear-gradient(110deg,rgba(255,70,70,.14),rgba(255,255,255,.02));}
373
+ .pk-pipe-step.current{transform:translateX(4px);border-color:var(--phase);background:linear-gradient(105deg,color-mix(in srgb,var(--phase) 19%,transparent),rgba(255,255,255,.035));box-shadow:0 0 0 1px color-mix(in srgb,var(--phase) 18%,transparent),0 8px 28px rgba(0,0,0,.22);}
374
+ .pk-pipe-step.current:after{content:'ACTIVE';position:absolute;right:-1.7rem;top:.45rem;transform:rotate(38deg);padding:.12rem 2rem;background:var(--phase);color:#080808;font-size:.42rem;font-weight:800;letter-spacing:.08em;}
375
+ .pk-pipe-step.current .step-index{box-shadow:0 0 14px color-mix(in srgb,var(--phase) 55%,transparent);animation:pulse 1.15s infinite;}
376
+ .pk-pipeline-summary{padding:0.7rem 0.75rem;border:1px solid var(--glass-border);border-radius:11px;background:linear-gradient(120deg,rgba(212,170,72,.08),rgba(255,255,255,.018));font-family:var(--mono);font-size:0.62rem;color:var(--muted);line-height:1.45;}
374
377
  .pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}
375
378
  .pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}
376
379
  .pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}
380
+ .pk-pipe-history{margin-top:.75rem;border-top:1px solid var(--glass-border);padding-top:.7rem}.pk-pipe-history-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;margin-bottom:.5rem}.pk-pipe-history-head b{font-family:var(--mono);font-size:.65rem;color:var(--ink);text-transform:uppercase;letter-spacing:.06em}.pk-pipe-history-list{display:grid;gap:.45rem}.pk-run-card{border:1px solid var(--glass-border);border-radius:10px;background:var(--panel-2);padding:.55rem .62rem;cursor:pointer}.pk-run-card:hover{border-color:var(--gold)}.pk-run-top{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:start}.pk-run-title{font-family:var(--mono);font-size:.61rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pk-run-meta{font-family:var(--mono);font-size:.53rem;color:var(--muted);margin-top:.17rem}.pk-run-state{font-family:var(--mono);font-size:.5rem;text-transform:uppercase;border:1px solid var(--glass-border);border-radius:999px;padding:.18rem .4rem}.pk-run-state.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}.pk-run-state.bad{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-run-state.running{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}.pk-run-stages{display:flex;gap:3px;margin-top:.45rem}.pk-run-stages i{height:4px;flex:1;border-radius:3px;background:rgba(255,255,255,.07)}.pk-run-stages i.ok{background:var(--ok)}.pk-run-stages i.failed,.pk-run-stages i.blocked{background:var(--bad)}.pk-history-pager{display:flex;align-items:center;gap:.35rem}.pk-history-pager span{font-family:var(--mono);font-size:.54rem;color:var(--muted)}
381
+ .pk-mic-suite{margin-top:.6rem;display:grid;gap:.55rem}.pk-mic-health{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem}.pk-mic-check{padding:.48rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-mic-check .k{font-family:var(--mono);font-size:.49rem;color:var(--muted);text-transform:uppercase}.pk-mic-check .v{font-family:var(--mono);font-size:.62rem;color:var(--ink);margin-top:.16rem;overflow-wrap:anywhere}.pk-mic-check.ok{border-color:color-mix(in srgb,var(--ok) 30%,transparent)}.pk-mic-check.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}.pk-mic-result{border:1px solid var(--glass-border);border-radius:10px;padding:.58rem;background:var(--panel-2);font-family:var(--mono);font-size:.58rem}.pk-mic-result.ok{border-color:var(--ok);background:rgba(60,180,110,.08)}.pk-mic-result.bad{border-color:var(--bad);background:rgba(220,80,80,.08)}.pk-mic-readings{display:grid;grid-template-columns:repeat(4,1fr);gap:.35rem;margin-top:.45rem}.pk-mic-reading{padding:.35rem;background:rgba(0,0,0,.16);border-radius:7px}.pk-mic-reading span{display:block;color:var(--muted);font-size:.48rem;text-transform:uppercase}.pk-mic-reading b{display:block;color:var(--ink);font-size:.67rem;margin-top:.1rem}
382
+ .pk-motor-registry{display:grid;gap:.4rem}.pk-motor-row{display:grid;grid-template-columns:1fr 4.5rem 5.5rem auto;gap:.35rem;align-items:end;padding:.45rem;border:1px solid var(--glass-border);border-radius:9px;background:var(--panel-2)}.pk-motor-canvas{display:flex;align-items:stretch;gap:.55rem;overflow-x:auto;padding:.65rem .2rem;min-height:130px}.pk-motor-node{min-width:165px;padding:.55rem;border:1px solid rgba(255,205,93,.35);border-radius:12px;background:linear-gradient(145deg,rgba(201,150,46,.13),rgba(255,255,255,.025));position:relative}.pk-motor-node:not(:last-child):after{content:'';position:absolute;right:-.56rem;top:50%;width:.56rem;border-top:2px solid var(--gold)}.pk-motor-node .node-num{font-family:var(--mono);font-size:.48rem;color:var(--gold);text-transform:uppercase;margin-bottom:.35rem}.pk-motor-node .rp-field{margin-bottom:.3rem}.pk-motor-empty{display:grid;place-items:center;width:100%;min-height:100px;border:1px dashed var(--glass-border);border-radius:10px;color:var(--muted);font-family:var(--mono);font-size:.58rem}.pk-motor-status{font-family:var(--mono);font-size:.58rem;padding:.5rem;border-radius:8px;background:var(--panel-2);border:1px solid var(--glass-border)}.pk-motor-status.ok{color:var(--ok);border-color:var(--ok)}.pk-motor-status.bad{color:var(--bad);border-color:var(--bad)}
383
+ @media(max-width:720px){.pk-pipe-step{grid-template-columns:1.8rem 1fr}.pk-pipe-step .detail{grid-column:2;text-align:left}.pk-mic-health{grid-template-columns:1fr}.pk-mic-readings{grid-template-columns:1fr 1fr}.pk-motor-row{grid-template-columns:1fr 4.5rem}}
377
384
  .pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}
378
385
  .pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
379
386
  .pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
@@ -4070,8 +4077,28 @@ button.act:disabled{opacity:0.5;cursor:default;}
4070
4077
  ];
4071
4078
  // Pond / curved water feature at the lower-left of the plaza.
4072
4079
  var POND={cx:0.18*15, cy:0.84*8, rx:2.4, ry:1.1};
4073
- // Street traffic along the top road (yellow taxis + private cars).
4074
- var CARS=[[2.2,-0.92,'#f2c14e'],[4.4,-0.78,'#e0a23c'],[6.6,-0.95,'#7fb4ff'],[8.8,-1.02,'#ff7e6e'],[11.0,-0.84,'#d8d8e0'],[13.2,-0.98,'#f2c14e']];
4080
+ // Street traffic along the top road. Each car has x, y (world), color,
4081
+ // a per-car speed (world units / second), a direction (+1 = right, -1 =
4082
+ // left), and an "oncoming" flag so the car sprite can face the right way
4083
+ // (headlight forward, taillight rear). y is one of two lane bands
4084
+ // (≈ -0.78 for outbound, ≈ -1.02 for inbound) so cars don't overlap.
4085
+ // When a car crosses either end of the road (x < -0.5 or x > PARK_W+0.5)
4086
+ // it wraps to the opposite side with a small y jitter so the flow
4087
+ // doesn't visibly reset.
4088
+ var ROAD_Y0=-1.5, ROAD_Y1=-0.35;
4089
+ var CARS=[
4090
+ {x:2.2, y:-0.78, color:'#f2c14e', speed:1.6, dir:+1},
4091
+ {x:5.6, y:-1.02, color:'#7fb4ff', speed:1.8, dir:-1},
4092
+ {x:7.4, y:-0.78, color:'#e0a23c', speed:1.4, dir:+1},
4093
+ {x:10.2, y:-1.02, color:'#ff7e6e', speed:2.0, dir:-1},
4094
+ {x:11.8, y:-0.78, color:'#d8d8e0', speed:1.5, dir:+1},
4095
+ {x:1.0, y:-1.02, color:'#5b8bd6', speed:1.7, dir:-1},
4096
+ {x:13.6, y:-0.78, color:'#c87ed4', speed:1.3, dir:+1},
4097
+ {x:8.4, y:-1.02, color:'#f2c14e', speed:1.9, dir:-1},
4098
+ ];
4099
+ // Per-car phase offsets so the headlights / taillights don't all blink
4100
+ // in lockstep — gives a natural "moving at different speeds" feel.
4101
+ var _carPhase = CARS.map(function(_, i){ return i * 0.7; });
4075
4102
  // ── Walking people — looping sprites that stroll along fixed plaza paths.
4076
4103
  // Each walker is a small canvas-drawn figure (head + body + two
4077
4104
  // swinging legs + swinging arms) with a vertical bob and a slight
@@ -4463,7 +4490,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
4463
4490
  if(fill){ X.fillStyle=fill; X.fill(); }
4464
4491
  if(stroke){ X.strokeStyle=stroke; X.lineWidth=lw||1; X.stroke(); }
4465
4492
  }
4466
- function drawGround(){
4493
+ function drawGround(now){
4494
+ // now is optional — only the road cars and water effects need a
4495
+ // wall-clock. We pass performance.now() in from frame() so every
4496
+ // animation reads the same time.
4497
+ if(typeof now !== 'number') now = performance.now();
4498
+ var _now = now;
4467
4499
  var z=cam.z;
4468
4500
  // ── grass base — multi-stop radial sun-lit gradient on a slightly larger field ──
4469
4501
  // Field is now sized to the plaza footprint + a small margin. The previous
@@ -4690,7 +4722,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
4690
4722
  poly([[0,-1.5],[PARK_W,-1.5],[PARK_W,-0.35],[0,-0.35]], 'rgba(26,26,32,0.92)', 'rgba(255,255,255,0.08)',1);
4691
4723
  X.strokeStyle='rgba(232,210,120,0.55)'; X.setLineDash([9*z,9*z]);
4692
4724
  var r1=iso(0.2,-0.92), r2=iso(PARK_W-0.2,-0.92); X.beginPath(); X.moveTo(r1.x,r1.y); X.lineTo(r2.x,r2.y); X.stroke(); X.setLineDash([]);
4693
- CARS.forEach(function(c){ drawCar(c[0],c[1],c[2]); });
4725
+ // animated road cars — positions updated each frame by tickCars()
4726
+ // draw order: outbound lane first (front), then inbound (back) for natural overlap
4727
+ for(var cai=0; cai<CARS.length; cai++){
4728
+ var car = CARS[cai];
4729
+ drawCar(car.x, car.y, car.color, car.dir, _carPhase[cai]||0, _now);
4730
+ }
4694
4731
  // white event tent in the left grass strip (west of the plaza)
4695
4732
  drawTent(-0.15, 0.5);
4696
4733
  // ── lake — wider sandy shore with soft radial fade to grass ──
@@ -4743,7 +4780,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4743
4780
  X.restore();
4744
4781
  // ── animated ripple/highlight — thin white elliptical arcs that drift across the lake ──
4745
4782
  var pondCent=iso(POND.cx, POND.cy);
4746
- var ripplePhase=Math.sin(performance.now()/1500);
4783
+ var ripplePhase=Math.sin(_now/1500);
4747
4784
  X.save();
4748
4785
  X.strokeStyle='rgba(232,248,252,0.7)'; X.lineWidth=1.0*z; X.lineCap='round';
4749
4786
  for(var rk=0;rk<4;rk++){
@@ -4754,7 +4791,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4754
4791
  var rCy2=POND.cy - 0.10 + rk*0.12;
4755
4792
  var rA2=iso(rCx2-rxr2, rCy2);
4756
4793
  var rB2=iso(rCx2+rxr2, rCy2);
4757
- X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(performance.now()/900 + rk));
4794
+ X.globalAlpha=0.35 + 0.4*Math.abs(Math.sin(_now/900 + rk));
4758
4795
  X.beginPath();
4759
4796
  X.moveTo(rA2.x, rA2.y);
4760
4797
  X.quadraticCurveTo(pondCent.x, rA2.y - 3*z, rB2.x, rB2.y);
@@ -4801,7 +4838,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
4801
4838
  // ── tiny fountain — 3 concentric rings of decreasing opacity around lake centroid ──
4802
4839
  X.save();
4803
4840
  for(var fk=0;fk<3;fk++){
4804
- var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(performance.now()/600 + fk));
4841
+ var fr=((fk+1)*0.18) + 0.06*Math.abs(Math.sin(_now/600 + fk));
4805
4842
  var falpha=0.55 - fk*0.18;
4806
4843
  var fcol='rgba(220,240,248,'+falpha.toFixed(3)+')';
4807
4844
  var fringPts=[];
@@ -4937,54 +4974,271 @@ button.act:disabled{opacity:0.5;cursor:default;}
4937
4974
  X.fillStyle='rgba(232,72,72,0.95)';
4938
4975
  X.beginPath(); X.moveTo(rdR.x,rdR.y-4*z); X.lineTo(rdR.x+2.5*z,rdR.y-3*z); X.lineTo(rdR.x,rdR.y-2*z); X.closePath(); X.fill();
4939
4976
  }
4940
- function drawCar(wx,wy,col){
4977
+ function drawCar(wx,wy,col,dir,phase,now){
4941
4978
  var p=iso(wx,wy), z=cam.z;
4942
- // soft drop shadow
4979
+ // Optional animation params: dir (+1 right / -1 left), phase (per-car
4980
+ // offset in seconds), now (performance.now in ms). Falls back to the
4981
+ // static parked-car look when these are undefined.
4982
+ var moving = (dir===1 || dir===-1) && typeof now === 'number';
4983
+ // gentle vertical bob so a moving car looks like it has suspension
4984
+ var bob = moving ? Math.sin((now/180 + (phase||0)*1000)) * 0.5*z : 0;
4985
+ // soft drop shadow — slight motion blur for moving cars
4943
4986
  X.fillStyle='rgba(0,0,0,0.32)';
4944
4987
  X.beginPath(); X.ellipse(p.x,p.y+4*z,13*z,5.5*z,0,0,7); X.fill();
4988
+ if(moving){
4989
+ // ground shadow streak behind the car (opposite to direction of travel)
4990
+ var sx = p.x - dir*8*z;
4991
+ X.fillStyle='rgba(0,0,0,0.10)';
4992
+ X.beginPath(); X.ellipse(sx, p.y+4.5*z, 9*z, 4*z, 0, 0, 7); X.fill();
4993
+ // soft headlight beam projected on the road in front of the car
4994
+ var beamFrontX = p.x + dir*16*z;
4995
+ var beam = X.createRadialGradient(beamFrontX, p.y+1*z, 1*z, beamFrontX, p.y+1*z, 14*z);
4996
+ beam.addColorStop(0, 'rgba(255,242,200,0.32)');
4997
+ beam.addColorStop(0.5, 'rgba(255,242,200,0.10)');
4998
+ beam.addColorStop(1, 'rgba(255,242,200,0)');
4999
+ X.save();
5000
+ X.fillStyle = beam;
5001
+ X.beginPath(); X.ellipse(beamFrontX, p.y+1*z, 14*z, 6*z, 0, 0, Math.PI*2); X.fill();
5002
+ X.restore();
5003
+ }
4945
5004
  // chassis (lower body)
4946
5005
  X.fillStyle=col;
4947
- X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z,26*z,11*z,3*z); X.fill();
5006
+ X.beginPath(); X.roundRect(p.x-13*z,p.y-5*z+bob,26*z,11*z,3*z); X.fill();
4948
5007
  // chassis bottom shade
4949
5008
  X.fillStyle='rgba(0,0,0,0.22)';
4950
- X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z,26*z,4*z,2*z); X.fill();
5009
+ X.beginPath(); X.roundRect(p.x-13*z,p.y+2*z+bob,26*z,4*z,2*z); X.fill();
4951
5010
  // roof (darker top)
4952
5011
  X.fillStyle='rgba(0,0,0,0.55)';
4953
- X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
5012
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();
4954
5013
  // roof tint with car color
4955
5014
  X.fillStyle=col;
4956
5015
  X.globalAlpha=0.6;
4957
- X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z,16*z,6*z,2.5*z); X.fill();
5016
+ X.beginPath(); X.roundRect(p.x-8*z,p.y-10*z+bob,16*z,6*z,2.5*z); X.fill();
4958
5017
  X.globalAlpha=1;
4959
5018
  // windshield highlight
4960
5019
  X.fillStyle='rgba(180,220,255,0.65)';
4961
- X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z,12*z,3*z,1.5*z); X.fill();
5020
+ X.beginPath(); X.roundRect(p.x-6*z,p.y-9*z+bob,12*z,3*z,1.5*z); X.fill();
4962
5021
  // windshield shine
4963
5022
  X.fillStyle='rgba(255,255,255,0.45)';
4964
5023
  X.beginPath();
4965
- X.moveTo(p.x-5*z,p.y-9*z); X.lineTo(p.x-2*z,p.y-9*z); X.lineTo(p.x-4*z,p.y-7*z); X.closePath(); X.fill();
4966
- // wheels
5024
+ X.moveTo(p.x-5*z,p.y-9*z+bob); X.lineTo(p.x-2*z,p.y-9*z+bob); X.lineTo(p.x-4*z,p.y-7*z+bob); X.closePath(); X.fill();
5025
+ // wheels — animated spokes when the car is moving (rotation = distance traveled)
4967
5026
  X.fillStyle='#0c0c10';
4968
- X.beginPath(); X.arc(p.x-8*z,p.y+5*z,2.6*z,0,7); X.fill();
4969
- X.beginPath(); X.arc(p.x+8*z,p.y+5*z,2.6*z,0,7); X.fill();
4970
- X.beginPath(); X.arc(p.x-8*z,p.y+6*z,2.6*z,0,7); X.fill();
4971
- X.beginPath(); X.arc(p.x+8*z,p.y+6*z,2.6*z,0,7); X.fill();
4972
- // wheel rims
5027
+ X.beginPath(); X.arc(p.x-8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();
5028
+ X.beginPath(); X.arc(p.x+8*z,p.y+5*z+bob,2.6*z,0,7); X.fill();
5029
+ X.beginPath(); X.arc(p.x-8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();
5030
+ X.beginPath(); X.arc(p.x+8*z,p.y+6*z+bob,2.6*z,0,7); X.fill();
5031
+ // wheel rims with a moving spoke mark so wheels visibly spin
4973
5032
  X.fillStyle='rgba(200,200,210,0.75)';
4974
- X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z,1*z,0,7); X.fill();
4975
- X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z,1*z,0,7); X.fill();
4976
- // headlight glow (frontright side)
5033
+ X.beginPath(); X.arc(p.x-8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();
5034
+ X.beginPath(); X.arc(p.x+8*z,p.y+5.5*z+bob,1*z,0,7); X.fill();
5035
+ // wheel spoke (rotates with speed) gives the impression of motion
5036
+ if(moving){
5037
+ var spokeAng = (now/40 + (phase||0)*1000) % (Math.PI*2);
5038
+ var spokeDx = Math.cos(spokeAng) * 1.6*z;
5039
+ var spokeDy = Math.sin(spokeAng) * 1.6*z;
5040
+ X.strokeStyle='rgba(220,220,230,0.85)'; X.lineWidth=0.5*z;
5041
+ X.beginPath(); X.moveTo(p.x-8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x-8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();
5042
+ X.beginPath(); X.moveTo(p.x+8*z - spokeDx, p.y+5.5*z+bob - spokeDy); X.lineTo(p.x+8*z + spokeDx, p.y+5.5*z+bob + spokeDy); X.stroke();
5043
+ }
5044
+ // headlight (front — the side the car is driving toward)
5045
+ var frontX = p.x + (dir||1)*12*z;
4977
5046
  X.fillStyle='rgba(255,240,180,0.95)';
4978
- X.beginPath(); X.arc(p.x+12*z,p.y-1*z,1.6*z,0,7); X.fill();
5047
+ X.beginPath(); X.arc(frontX, p.y-1*z+bob, 1.6*z, 0, 7); X.fill();
4979
5048
  X.fillStyle='rgba(255,240,180,0.4)';
4980
- X.beginPath(); X.arc(p.x+12*z,p.y-1*z,3*z,0,7); X.fill();
4981
- // tail light (rear — left side)
5049
+ X.beginPath(); X.arc(frontX, p.y-1*z+bob, 3*z, 0, 7); X.fill();
5050
+ // taillight (rear — opposite side, slightly redder)
5051
+ var rearX = p.x - (dir||1)*12*z;
4982
5052
  X.fillStyle='rgba(255,80,80,0.85)';
4983
- X.beginPath(); X.arc(p.x-12*z,p.y-1*z,1.2*z,0,7); X.fill();
5053
+ X.beginPath(); X.arc(rearX, p.y-1*z+bob, 1.2*z, 0, 7); X.fill();
5054
+ if(moving){
5055
+ // a small red glow on the road behind the car
5056
+ X.fillStyle='rgba(255,80,80,0.25)';
5057
+ X.beginPath(); X.arc(rearX - dir*3*z, p.y+1*z+bob, 2.6*z, 0, 7); X.fill();
5058
+ }
4984
5059
  // body highlight
4985
5060
  X.fillStyle='rgba(255,255,255,0.18)';
4986
- X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z,24*z,1.5*z,1*z); X.fill();
5061
+ X.beginPath(); X.roundRect(p.x-12*z,p.y-4*z+bob,24*z,1.5*z,1*z); X.fill();
5062
+ }
5063
+
5064
+ // ── Atmosphere: sky, sun, distant mountains, clouds, haze ──
5065
+ // Painted on top of the cleared canvas and below the rest of the map
5066
+ // so the map looks like it sits in an actual world instead of on a
5067
+ // blank panel. Cheap to render (a handful of full-canvas fills + a
5068
+ // few silhouette paths + a small cloud set) and turns the whole
5069
+ // scene from "miniature on a black velvet" into "park at golden hour".
5070
+ //
5071
+ // Everything is drawn in screen space, not world space, because the
5072
+ // sky doesn't follow the iso projection — it's a backdrop, not a
5073
+ // surface. Distant mountains live just above the road horizon line,
5074
+ // the sun is fixed near the upper-right (a warm focal point), and a
5075
+ // handful of soft clouds drift slowly across the sky.
5076
+ function drawAtmosphere(now){
5077
+ if(!W || !H) return;
5078
+ var z = cam.z;
5079
+
5080
+ // ── sky gradient — warm late-afternoon palette (top to bottom) ──
5081
+ // Top: deep cobalt → mid: soft sky → low: warm gold haze. Anchored
5082
+ // to a virtual horizon line ~38% from the top of the stage so the
5083
+ // map always sits on the same horizon regardless of zoom.
5084
+ var horizon = H * 0.42;
5085
+ var sky = X.createLinearGradient(0, 0, 0, H);
5086
+ sky.addColorStop(0, 'rgba(46, 78, 132, 0.95)'); // deep sky
5087
+ sky.addColorStop(0.30, 'rgba(108, 152, 196, 0.85)'); // mid sky
5088
+ sky.addColorStop(0.46, 'rgba(214, 184, 138, 0.78)'); // warm horizon
5089
+ sky.addColorStop(0.55, 'rgba(244, 222, 168, 0.62)'); // soft golden glow
5090
+ sky.addColorStop(0.75, 'rgba(248, 232, 200, 0.20)'); // fades into the ground area
5091
+ sky.addColorStop(1, 'rgba(248, 232, 200, 0)');
5092
+ X.fillStyle = sky;
5093
+ X.fillRect(0, 0, W, H);
5094
+
5095
+ // ── sun — a soft warm disc with a wide glow halo ──
5096
+ // Anchored at ~78% across, 22% down so it reads as the off-screen
5097
+ // light source warming the whole scene. The glow pulses very
5098
+ // gently so it doesn't feel like a static sticker.
5099
+ var sunX = W * 0.78;
5100
+ var sunY = H * 0.22;
5101
+ var sunR = 28 * (0.95 + 0.05 * Math.sin(now/2400));
5102
+ // outer warm glow
5103
+ var glow = X.createRadialGradient(sunX, sunY, sunR*0.6, sunX, sunY, sunR*7);
5104
+ glow.addColorStop(0, 'rgba(255, 222, 140, 0.55)');
5105
+ glow.addColorStop(0.25,'rgba(255, 210, 130, 0.30)');
5106
+ glow.addColorStop(0.6, 'rgba(255, 200, 120, 0.08)');
5107
+ glow.addColorStop(1, 'rgba(255, 200, 120, 0)');
5108
+ X.fillStyle = glow;
5109
+ X.fillRect(0, 0, W, H);
5110
+ // sun disc
5111
+ var disc = X.createRadialGradient(sunX, sunY, sunR*0.2, sunX, sunY, sunR);
5112
+ disc.addColorStop(0, 'rgba(255, 250, 230, 1)');
5113
+ disc.addColorStop(0.6, 'rgba(255, 232, 170, 0.95)');
5114
+ disc.addColorStop(1, 'rgba(255, 210, 130, 0)');
5115
+ X.fillStyle = disc;
5116
+ X.beginPath(); X.arc(sunX, sunY, sunR, 0, Math.PI*2); X.fill();
5117
+
5118
+ // ── distant mountain silhouette (3 receding layers) ──
5119
+ // Each layer uses a slightly lighter color so the eye reads depth.
5120
+ // Anchored just below the horizon so the iso map sits in front of
5121
+ // them. Heights/shapes are deterministic so the silhouette doesn't
5122
+ // shimmer between frames.
5123
+ function mountainLayer(baseY, amp, segments, color, seedOff){
5124
+ X.fillStyle = color;
5125
+ X.beginPath();
5126
+ X.moveTo(0, H);
5127
+ // sample the silhouette using a sum of low-frequency sines
5128
+ for(var mx=0; mx<=W; mx+=8){
5129
+ var t = mx / W;
5130
+ var h = (
5131
+ Math.sin(t*6.3 + seedOff) * 0.5 +
5132
+ Math.sin(t*12.5 + seedOff*2.3) * 0.28 +
5133
+ Math.sin(t*22.1 + seedOff*0.7) * 0.14
5134
+ ) * amp;
5135
+ X.lineTo(mx, baseY - h);
5136
+ }
5137
+ X.lineTo(W, H);
5138
+ X.closePath();
5139
+ X.fill();
5140
+ }
5141
+ // farther layer (lighter haze, lower)
5142
+ mountainLayer(H*0.46, 28, 18, 'rgba(120, 132, 158, 0.45)', 1.7);
5143
+ // middle layer
5144
+ mountainLayer(H*0.48, 36, 16, 'rgba(86, 100, 124, 0.55)', 3.1);
5145
+ // closer ridge (darkest, in front of the plaza)
5146
+ mountainLayer(H*0.50, 22, 14, 'rgba(54, 68, 86, 0.60)', 0.4);
5147
+
5148
+ // ── soft cloud band — 5 elongated ellipses drifting right ──
5149
+ // Drawn as flat white ellipses with low alpha so they look like
5150
+ // wisps, not puffs. Each cloud has its own period so they don't
5151
+ // march in lockstep.
5152
+ var cloudColors = [
5153
+ 'rgba(255,255,255,0.55)',
5154
+ 'rgba(255,250,240,0.45)',
5155
+ 'rgba(248,242,228,0.40)',
5156
+ 'rgba(255,250,235,0.35)',
5157
+ 'rgba(252,246,232,0.50)',
5158
+ ];
5159
+ for(var ci=0; ci<cloudColors.length; ci++){
5160
+ var period = 90 + ci*22; // seconds for a full pass
5161
+ var drift = ((now/1000) / period) % 1; // 0..1
5162
+ var cx = (drift * (W + 240)) - 120; // wrap from -120 to W+120
5163
+ var cy = H*0.10 + ci*16;
5164
+ var rx = 60 + ci*8;
5165
+ var ry = 10 + (ci%3)*3;
5166
+ X.fillStyle = cloudColors[ci];
5167
+ // fluffy edge: 3 overlapping ellipses
5168
+ X.beginPath(); X.ellipse(cx, cy, rx, ry, 0, 0, Math.PI*2); X.fill();
5169
+ X.beginPath(); X.ellipse(cx - rx*0.5, cy + ry*0.4, rx*0.7, ry*0.7, 0, 0, Math.PI*2); X.fill();
5170
+ X.beginPath(); X.ellipse(cx + rx*0.5, cy + ry*0.3, rx*0.7, ry*0.6, 0, 0, Math.PI*2); X.fill();
5171
+ }
5172
+
5173
+ // ── soft warm light wash on the upper third of the map ──
5174
+ // Sits in screen space too: a wide radial glow over the entire
5175
+ // map area that makes the grass + plaza read as if lit from the
5176
+ // direction of the sun (upper-right). Without this the map looks
5177
+ // like it was cut out and pasted in.
5178
+ var wash = X.createRadialGradient(sunX, sunY*0.7, 40, sunX, sunY*0.7, Math.max(W, H)*0.85);
5179
+ wash.addColorStop(0, 'rgba(255, 220, 150, 0.16)');
5180
+ wash.addColorStop(0.35, 'rgba(255, 200, 130, 0.06)');
5181
+ wash.addColorStop(1, 'rgba(255, 200, 130, 0)');
5182
+ X.fillStyle = wash;
5183
+ X.fillRect(0, 0, W, H);
5184
+
5185
+ // ── subtle haze band along the horizon ──
5186
+ // Sits just above the road so the cars pop against a soft
5187
+ // horizon-glow rather than a hard line.
5188
+ var haze = X.createLinearGradient(0, horizon - 30, 0, horizon + 50);
5189
+ haze.addColorStop(0, 'rgba(255, 220, 160, 0)');
5190
+ haze.addColorStop(0.5, 'rgba(255, 220, 160, 0.22)');
5191
+ haze.addColorStop(1, 'rgba(255, 220, 160, 0)');
5192
+ X.fillStyle = haze;
5193
+ X.fillRect(0, horizon - 30, W, 80);
5194
+
5195
+ // ── ambient particles — drifting motes / pollen / fireflies ──
5196
+ // 40 tiny dots that float across the scene. Each one is held in
5197
+ // an array; the array is seeded once (so they don't all start at
5198
+ // the same position) and advanced by a tiny per-frame velocity.
5199
+ // On first call we lazily seed it; on resize we re-distribute.
5200
+ if(!_atmos.particles){
5201
+ _atmos.particles = [];
5202
+ for(var pi=0; pi<40; pi++){
5203
+ _atmos.particles.push({
5204
+ x: Math.random()*W,
5205
+ y: H*0.20 + Math.random()*H*0.55,
5206
+ r: 0.8 + Math.random()*1.6,
5207
+ vx: -6 + Math.random()*12, // px/sec, mostly right
5208
+ vy: -2 + Math.random()*4, // slight up/down sway
5209
+ phase: Math.random()*Math.PI*2,
5210
+ twinkle: 0.5 + Math.random()*0.5, // brightness amplitude
5211
+ hue: Math.random() < 0.35 ? 'warm' : 'cool', // mix of gold + silver
5212
+ });
5213
+ }
5214
+ }
5215
+ var dt = _atmos.lastNow ? Math.min(64, now - _atmos.lastNow)/1000 : 0.016;
5216
+ _atmos.lastNow = now;
5217
+ for(var pj=0; pj<_atmos.particles.length; pj++){
5218
+ var pt = _atmos.particles[pj];
5219
+ // simple wrap
5220
+ pt.x += pt.vx * dt;
5221
+ pt.y += Math.sin(now/1200 + pt.phase) * 0.3;
5222
+ if(pt.x > W + 8) pt.x = -8;
5223
+ if(pt.x < -8) pt.x = W + 8;
5224
+ if(pt.y < H*0.10) pt.y = H*0.75;
5225
+ if(pt.y > H*0.80) pt.y = H*0.15;
5226
+ // soft twinkle alpha
5227
+ var a = 0.25 + 0.45 * Math.abs(Math.sin(now/700 + pt.phase)) * pt.twinkle;
5228
+ X.fillStyle = pt.hue === 'warm'
5229
+ ? 'rgba(255, 230, 160, '+a.toFixed(3)+')'
5230
+ : 'rgba(220, 235, 250, '+a.toFixed(3)+')';
5231
+ X.beginPath(); X.arc(pt.x, pt.y, pt.r, 0, Math.PI*2); X.fill();
5232
+ // tiny halo
5233
+ X.fillStyle = pt.hue === 'warm'
5234
+ ? 'rgba(255, 220, 140, '+(a*0.35).toFixed(3)+')'
5235
+ : 'rgba(200, 220, 245, '+(a*0.30).toFixed(3)+')';
5236
+ X.beginPath(); X.arc(pt.x, pt.y, pt.r*2.5, 0, Math.PI*2); X.fill();
5237
+ }
4987
5238
  }
5239
+ // Holds the persistent atmospheric state (particle positions + last tick time).
5240
+ var _atmos = { particles: null, lastNow: 0 };
5241
+
4988
5242
  // Sun-shade pavilion: 4 thin white poles supporting a flat white square canopy.
4989
5243
  function drawPavilion(wx,wy){
4990
5244
  var p=iso(wx,wy), z=cam.z;
@@ -5839,6 +6093,26 @@ button.act:disabled{opacity:0.5;cursor:default;}
5839
6093
  }
5840
6094
  }
5841
6095
  }
6096
+ // Cars share the same elapsed-dt clock so a tab-switch doesn't
6097
+ // catapult every car off the road at once. speed is world units
6098
+ // per second, dt is milliseconds.
6099
+ var cdt = Math.min(64, dt);
6100
+ for(var ci=0; ci<CARS.length; ci++){
6101
+ var car = CARS[ci];
6102
+ car.x += car.dir * car.speed * (cdt/1000);
6103
+ // Wrap the car around the road. Add a small y-jitter so the
6104
+ // re-entry point doesn't visibly snap.
6105
+ if(car.dir > 0 && car.x > PARK_W + 0.5){
6106
+ car.x = -0.5;
6107
+ // bounce across the centerline by switching y + dir
6108
+ car.y = (car.y < -0.9) ? -0.78 : -1.02;
6109
+ car.dir = (car.y < -0.9) ? +1 : -1;
6110
+ } else if(car.dir < 0 && car.x < -0.5){
6111
+ car.x = PARK_W + 0.5;
6112
+ car.y = (car.y < -0.9) ? -0.78 : -1.02;
6113
+ car.dir = (car.y < -0.9) ? +1 : -1;
6114
+ }
6115
+ }
5842
6116
  }
5843
6117
  function walkerPos(w){
5844
6118
  // Decode w.distance (in units of "segments") into a (seg, t) so
@@ -5952,8 +6226,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
5952
6226
  if(!running) return;
5953
6227
  var now = performance.now();
5954
6228
  X.clearRect(0,0,W,H);
5955
- drawGround();
5956
- // step the looping walkers along their fixed paths
6229
+ // ── atmosphere: sky + sun + clouds + distant mountains + haze ──
6230
+ // Painted first, behind everything else, so the rest of the map
6231
+ // sits on top of an actual horizon instead of on the empty page
6232
+ // background. Drawn every frame so the sun and clouds drift even
6233
+ // when the rest of the scene is static.
6234
+ drawAtmosphere(now);
6235
+ drawGround(now);
6236
+ // step the looping walkers (and road cars) along their paths
5957
6237
  tickWalkers(now);
5958
6238
  // Re-derive status every frame so the "ended" countdown (a wall-
5959
6239
  // clock timer) decrements smoothly between telemetry polls (3s).
@@ -6721,6 +7001,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6721
7001
  + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-unattended-test">Run unattended readiness</button><button class="rp-btn" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
6722
7002
  + '<div class="pk-note" id="pk-unattended-result">Unattended readiness runs an acoustic speaker/mic round-trip, triggers the production motion path, and requires greeting playback on the robot.</div>'
6723
7003
  + '<div class="pk-pipeline" id="pk-pipeline"></div>')
7004
+ + sect('Production run history','<div class="pk-pipe-history" style="margin-top:0;padding-top:0;border-top:0"><div class="pk-pipe-history-head"><b>Persistent sessions</b><div class="pk-history-pager"><button class="rp-btn" id="pk-history-prev">Prev</button><span id="pk-history-page">page 1</span><button class="rp-btn" id="pk-history-next">Next</button></div></div><div class="pk-pipe-history-list" id="pk-pipeline-history"><div class="pk-note">Loading persistent sessions...</div></div></div>')
6724
7005
  + 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>')
6725
7006
  + 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>'
6726
7007
  + '<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);">'
@@ -6743,6 +7024,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
6743
7024
  + 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>')
6744
7025
  + buildShellSection(schedId(n))
6745
7026
  + 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>');
7027
+ b.innerHTML += sect('Microphone setup suite','<div class="pk-mic-suite"><div class="pk-mic-health" id="pk-mic-health"><div class="pk-mic-check"><div class="k">Hardware lock</div><div class="v">USB mic hw:3,0</div></div><div class="pk-mic-check"><div class="k">PCM capture</div><div class="v">not tested</div></div><div class="pk-mic-check"><div class="k">Production track</div><div class="v">waiting</div></div></div><div class="rp-form" style="grid-template-columns:1fr 1fr;gap:.4rem"><div class="rp-field"><label>Ground-truth capture</label><select id="pk-mic-duration"><option value="2">2 seconds</option><option value="4" selected>4 seconds</option><option value="8">8 seconds</option></select></div><div class="rp-field"><label>Locked input</label><div class="pk-note" style="margin:.45rem 0 0">USB microphone (plughw:3,0)</div></div></div><div class="pk-ctl"><button class="rp-btn primary" id="pk-mic-groundtruth">Run raw mic capture</button><button class="rp-btn" id="pk-mic-roundtrip">Run speaker + mic test</button><button class="rp-btn" id="pk-mic-apply">Reapply production profile</button></div><div class="pk-note" id="pk-mic-test-state">Raw capture bypasses LiveKit and tests the exact ALSA path. Round-trip then verifies the physical speaker-to-mic path.</div><div id="pk-mic-test-result"></div></div>');
7028
+ b.innerHTML += sect('Motor registry + sequence canvas','<div class="rp-field"><label>Robot-local motor server</label><input id="pk-motor-url" value="http://127.0.0.1:8001" placeholder="http://127.0.0.1:8001"></div><div class="pk-motor-registry" id="pk-motor-registry"></div><button class="rp-btn" id="pk-motor-add" style="margin-top:.4rem">Add registered motor</button><div class="rp-form" style="grid-template-columns:1fr 1fr;margin-top:.7rem"><div class="rp-field"><label>Saved sequence</label><select id="pk-motor-sequence"></select></div><div class="rp-field"><label>Sequence name</label><input id="pk-motor-sequence-name" placeholder="Greeting motion"></div></div><div class="pk-motor-canvas" id="pk-motor-canvas"></div><div class="pk-ctl"><button class="rp-btn" id="pk-motor-step-add">Add timed node</button><button class="rp-btn primary" id="pk-motor-save">Save motor profile</button><button class="rp-btn" id="pk-motor-run">Run selected sequence</button></div><label class="pk-prodtoggle"><input type="checkbox" id="pk-motor-greeting"><span>Run this sequence automatically with the greeting pipeline</span></label><div class="pk-motor-status" id="pk-motor-status">Loading motor profile...</div>');
6746
7029
  b.innerHTML += sect('Production camera + audio','<div class="pk-note" id="pk-device-search" style="margin-bottom:.55rem">Hardware locked for launch. Camera, microphone, and speaker cannot drift between restarts.</div><div class="rp-field"><label>Camera input</label><select id="pk-video-device" disabled></select></div><div class="rp-field"><label>Microphone input</label><select id="pk-audio-input" disabled></select></div><div class="rp-field"><label>Speaker output</label><select id="pk-audio-output" disabled></select></div><button class="rp-btn primary" id="pk-device-test-speaker" style="margin-top:0.35rem">Test USB 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 greetings + enforce hardware</button><div class="pk-note" id="pk-device-note">waiting for robot heartbeat inventory</div>');
6747
7030
  // Put live hardware selection beside the camera and audio tests,
6748
7031
  // ahead of persona and service administration in the Park drawer.
@@ -6765,6 +7048,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6765
7048
  });
6766
7049
  var micListen=$('pk-mic-listen');if(micListen)micListen.addEventListener('click',function(){setMicMonitorAudible(!micMonitorAudible);});
6767
7050
  var micVolume=$('pk-mic-volume');if(micVolume)micVolume.addEventListener('input',function(){micMonitorVolume=Math.max(0,Math.min(1,Number(micVolume.value||80)/100));if(micMonitorGain)micMonitorGain.gain.value=micMonitorAudible?micMonitorVolume:0;});
7051
+ var micGround=$('pk-mic-groundtruth');if(micGround)micGround.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicGroundtruth(n);});
7052
+ var micRound=$('pk-mic-roundtrip');if(micRound)micRound.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMicRoundtrip(n);});
7053
+ var micApply=$('pk-mic-apply');if(micApply)micApply.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveDeviceSelectors(n);});
7054
+ var motorAdd=$('pk-motor-add');if(motorAdd)motorAdd.addEventListener('click',function(){motorEditorAddMotor();});
7055
+ var motorStep=$('pk-motor-step-add');if(motorStep)motorStep.addEventListener('click',function(){motorEditorAddStep();});
7056
+ var motorSave=$('pk-motor-save');if(motorSave)motorSave.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}saveMotorProfile(n);});
7057
+ var motorRun=$('pk-motor-run');if(motorRun)motorRun.addEventListener('click',function(){if(rpReadOnly){showOut('read-only node');return;}runMotorSequence(n);});
7058
+ var motorSeq=$('pk-motor-sequence');if(motorSeq)motorSeq.addEventListener('change',function(){selectMotorSequence(motorSeq.value);});
7059
+ var motorRegistry=$('pk-motor-registry');if(motorRegistry)motorRegistry.addEventListener('click',function(ev){var button=ev.target.closest('[data-motor-action]');if(!button)return;var index=Number(button.dataset.index);if(button.dataset.motorAction==='remove'){motorProfile.registry.splice(index,1);renderMotorEditor();}else if(button.dataset.motorAction==='test'){testMotorRelay(n,index);}});
7060
+ var motorCanvas=$('pk-motor-canvas');if(motorCanvas)motorCanvas.addEventListener('click',function(ev){var button=ev.target.closest('[data-step-remove]');if(button){captureMotorEditor();motorEditorSequence().steps.splice(Number(button.dataset.stepRemove),1);renderMotorEditor();}});
7061
+ loadMotorProfile(n);
6768
7062
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
6769
7063
  _bindShellSection(n);
6770
7064
  _spkTestRenderHistory(schedId(n));
@@ -6792,6 +7086,11 @@ button.act:disabled{opacity:0.5;cursor:default;}
6792
7086
  var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });
6793
7087
  var unattended=$('pk-unattended-test'); if(unattended) unattended.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } runUnattendedReadiness(n); });
6794
7088
  var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });
7089
+ var historyPrev=$('pk-history-prev');if(historyPrev)historyPrev.addEventListener('click',function(){pipelineHistoryPage=Math.max(1,pipelineHistoryPage-1);loadPipelineHistory(n);});
7090
+ var historyNext=$('pk-history-next');if(historyNext)historyNext.addEventListener('click',function(){pipelineHistoryPage+=1;loadPipelineHistory(n);});
7091
+ var historyList=$('pk-pipeline-history');if(historyList)historyList.addEventListener('click',function(ev){var card=ev.target.closest('.pk-run-card');if(card)openSessionDetailModal({session_id:card.dataset.session});});
7092
+ pipelineHistoryPage=1;
7093
+ loadPipelineHistory(n);
6795
7094
  pollPipeline(n);
6796
7095
  if(pipelineTimer) clearInterval(pipelineTimer);
6797
7096
  pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);
@@ -6806,12 +7105,82 @@ button.act:disabled{opacity:0.5;cursor:default;}
6806
7105
  refreshDrawer();
6807
7106
  }
6808
7107
 
7108
+ var motorProfile={registry:[],sequences:[],greeting_sequence_id:null,motor_server_url:'http://127.0.0.1:8001'};
7109
+ var motorSequenceId='';
7110
+ function motorEditorSequence(){
7111
+ var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});
7112
+ if(!sequence){sequence={id:motorSequenceId||('sequence-'+Date.now()),name:'New sequence',steps:[]};motorSequenceId=sequence.id;motorProfile.sequences.push(sequence);}
7113
+ if(!Array.isArray(sequence.steps))sequence.steps=[];
7114
+ return sequence;
7115
+ }
7116
+ function captureMotorEditor(){
7117
+ var rows=document.querySelectorAll('#pk-motor-registry .pk-motor-row');
7118
+ if(rows.length)motorProfile.registry=Array.prototype.map.call(rows,function(row,index){
7119
+ var fields=row.querySelectorAll('input');
7120
+ return {id:slugify(fields[0].value)||('motor-'+(index+1)),name:fields[1].value.trim()||('Motor '+(index+1)),gpio:Number(fields[2].value),active_high:fields[3].checked,max_duration_ms:Number(fields[4].value)||3000};
7121
+ });
7122
+ var sequence=(motorProfile.sequences||[]).find(function(item){return item.id===motorSequenceId;});
7123
+ if(sequence){
7124
+ var name=$('pk-motor-sequence-name');if(name)sequence.name=name.value.trim()||sequence.name||'Sequence';
7125
+ sequence.steps=Array.prototype.map.call(document.querySelectorAll('#pk-motor-canvas .pk-motor-node'),function(node){
7126
+ return {motor_id:node.querySelector('[data-step-motor]').value,delay_ms:Number(node.querySelector('[data-step-delay]').value)||0,duration_ms:Number(node.querySelector('[data-step-duration]').value)||300};
7127
+ });
7128
+ var greeting=$('pk-motor-greeting');if(greeting)motorProfile.greeting_sequence_id=greeting.checked?sequence.id:(motorProfile.greeting_sequence_id===sequence.id?null:motorProfile.greeting_sequence_id);
7129
+ }
7130
+ var url=$('pk-motor-url');if(url)motorProfile.motor_server_url=url.value.trim()||'http://127.0.0.1:8001';
7131
+ }
7132
+ function renderMotorEditor(){
7133
+ var registry=$('pk-motor-registry'),canvas=$('pk-motor-canvas'),select=$('pk-motor-sequence');if(!registry||!canvas||!select)return;
7134
+ registry.innerHTML=(motorProfile.registry||[]).map(function(motor,index){return '<div class="pk-motor-row"><input value="'+esc(motor.id)+'" aria-label="Motor id"><input value="'+esc(motor.name)+'" aria-label="Motor name"><input type="number" min="2" max="27" value="'+esc(motor.gpio)+'" aria-label="BCM GPIO"><label title="Active high"><input type="checkbox" '+(motor.active_high!==false?'checked':'')+'> high</label><input type="number" min="50" max="10000" step="50" value="'+esc(motor.max_duration_ms||3000)+'" aria-label="Max pulse milliseconds"><button class="rp-btn" data-motor-action="test" data-index="'+index+'">Test</button><button class="rp-btn danger" data-motor-action="remove" data-index="'+index+'">Remove</button></div>';}).join('')||'<div class="pk-note">No relays registered. Add only wired BCM outputs.</div>';
7135
+ select.innerHTML=(motorProfile.sequences||[]).map(function(sequence){return '<option value="'+esc(sequence.id)+'" '+(sequence.id===motorSequenceId?'selected':'')+'>'+esc(sequence.name||sequence.id)+'</option>';}).join('')+'<option value="__new__">+ New sequence</option>';
7136
+ var sequence=motorEditorSequence(),name=$('pk-motor-sequence-name'),greeting=$('pk-motor-greeting'),url=$('pk-motor-url');
7137
+ if(name)name.value=sequence.name||'';if(greeting)greeting.checked=motorProfile.greeting_sequence_id===sequence.id;if(url)url.value=motorProfile.motor_server_url||'http://127.0.0.1:8001';
7138
+ var motorOptions=(motorProfile.registry||[]).map(function(motor){return '<option value="'+esc(motor.id)+'">'+esc(motor.name+' | BCM '+motor.gpio)+'</option>';}).join('');
7139
+ canvas.innerHTML=(sequence.steps||[]).map(function(step,index){return '<div class="pk-motor-node"><div class="pk-motor-node-num">'+(index+1)+'</div><label>Relay<select data-step-motor>'+motorOptions.replace('value="'+esc(step.motor_id)+'"','value="'+esc(step.motor_id)+'" selected')+'</select></label><label>Delay ms<input data-step-delay type="number" min="0" max="30000" step="50" value="'+esc(step.delay_ms||0)+'"></label><label>Pulse ms<input data-step-duration type="number" min="50" max="10000" step="50" value="'+esc(step.duration_ms||300)+'"></label><button class="rp-btn danger" data-step-remove="'+index+'">Remove</button></div>';}).join('')||'<div class="pk-note">No timed nodes. Add a node to build the sequence.</div>';
7140
+ }
7141
+ function selectMotorSequence(id){
7142
+ captureMotorEditor();
7143
+ if(id==='__new__'){var sequence={id:'sequence-'+Date.now(),name:'New sequence',steps:[]};motorProfile.sequences.push(sequence);motorSequenceId=sequence.id;}else motorSequenceId=id;
7144
+ renderMotorEditor();
7145
+ }
7146
+ function motorEditorAddMotor(){captureMotorEditor();var number=motorProfile.registry.length+1;motorProfile.registry.push({id:'motor-'+number,name:'Motor '+number,gpio:Math.min(27,Math.max(2,number+4)),active_high:true,max_duration_ms:3000});renderMotorEditor();}
7147
+ function motorEditorAddStep(){captureMotorEditor();if(!motorProfile.registry.length){motorStatus('Register a motor before adding a timed node','bad');return;}motorEditorSequence().steps.push({motor_id:motorProfile.registry[0].id,delay_ms:0,duration_ms:300});renderMotorEditor();}
7148
+ function motorStatus(message,kind){var host=$('pk-motor-status');if(host){host.textContent=message;host.className='pk-motor-status '+(kind||'');}}
7149
+ function loadMotorProfile(n){
7150
+ var robotId=schedId(n);motorStatus('Loading saved motor profile...');
7151
+ getJson('/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile').then(function(data){motorProfile={registry:data.registry||[],sequences:data.sequences||[],greeting_sequence_id:data.greeting_sequence_id||null,motor_server_url:(schedulerDevice(n)||{}).motor_server_url||'http://127.0.0.1:8001'};if(!motorProfile.sequences.length)motorProfile.sequences=[{id:'greeting-motion',name:'Greeting motion',steps:[]}];motorSequenceId=motorProfile.greeting_sequence_id||motorProfile.sequences[0].id;renderMotorEditor();motorStatus('Saved profile loaded | '+motorProfile.registry.length+' relay(s), '+motorProfile.sequences.length+' sequence(s)','ok');}).catch(function(error){motorStatus('Motor profile unavailable: '+error.message,'bad');});
7152
+ }
7153
+ function saveMotorProfile(n){
7154
+ captureMotorEditor();var robotId=schedId(n),url=motorProfile.motor_server_url;
7155
+ var parsedUrl=null;try{parsedUrl=new URL(url);}catch(e){}
7156
+ if(!parsedUrl||parsedUrl.protocol!=='http:'||(parsedUrl.hostname!=='127.0.0.1'&&parsedUrl.hostname!=='localhost')||!parsedUrl.port){motorStatus('Motor server must be robot-local: http://127.0.0.1:PORT','bad');return Promise.reject(new Error('motor server must be localhost'));}
7157
+ motorStatus('Validating and saving motor profile...');
7158
+ return rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(robotId),{motor_server_url:url}).then(function(){return rpAction('PUT','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-profile',{registry:motorProfile.registry,sequences:motorProfile.sequences,greeting_sequence_id:motorProfile.greeting_sequence_id});}).then(function(data){motorProfile.registry=data.registry||[];motorProfile.sequences=data.sequences||[];motorProfile.greeting_sequence_id=data.greeting_sequence_id||null;renderMotorEditor();motorStatus('Profile saved. Greeting motion will start on first TTS playback.','ok');return data;}).catch(function(error){motorStatus('Save failed: '+error.message,'bad');throw error;});
7159
+ }
7160
+ function runMotorSequence(n){
7161
+ var robotId=schedId(n),sequence=motorEditorSequence();motorStatus('Saving and queueing '+sequence.name+'...');
7162
+ saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motor-sequences/'+encodeURIComponent(sequence.id)+'/run',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-sequence');}).then(function(result){motorStatus(result.ok?'Sequence completed on robot.':'Sequence failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Run failed: '+error.message,'bad');});
7163
+ }
7164
+ function testMotorRelay(n,index){
7165
+ captureMotorEditor();var motor=motorProfile.registry[index];if(!motor)return;var robotId=schedId(n);motorStatus('Saving profile before safe 300 ms '+motor.name+' test...');
7166
+ saveMotorProfile(n).then(function(){return rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/motors/'+encodeURIComponent(motor.id)+'/test?duration_ms=300',{});}).then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','motor-test');}).then(function(result){motorStatus(result.ok?motor.name+' relay test completed.':'Relay test failed: '+(result.error||'unknown error'),result.ok?'ok':'bad');}).catch(function(error){motorStatus('Relay test failed: '+error.message,'bad');});
7167
+ }
7168
+
7169
+ var pipelineHistoryPage=1;
6809
7170
  var PIPELINE_LABELS={
6810
7171
  robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',
6811
7172
  motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',
6812
7173
  microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',
6813
- tts_subscribed:'TTS subscribed',playback_started:'Robot playback',session_ended:'Session ended'
7174
+ tts_subscribed:'TTS subscribed',playback_started:'Robot playback',motor_sequence:'Greeting motion',session_ended:'Session ended'
6814
7175
  };
7176
+ var PIPELINE_PHASES={robot_online:'hardware',camera_ready:'hardware',microphone_ready:'hardware',speaker_ready:'hardware',motion_detected:'trigger',scheduler_session:'session',livekit_join:'session',camera_published:'media',microphone_published:'media',voice_worker:'intelligence',stt_listening:'intelligence',llm_response:'intelligence',tts_subscribed:'playback',playback_started:'playback',motor_sequence:'playback',session_ended:'close'};
7177
+ function pipelineCurrentStage(stages,latest,running){
7178
+ for(var i=0;i<stages.length;i++){var bad=latest[stages[i]];if(bad&&(bad.status==='failed'||bad.status==='blocked'))return stages[i];}
7179
+ if(!running)return null;
7180
+ var lastOk=-1;
7181
+ for(var j=0;j<stages.length;j++){var ev=latest[stages[j]];if(ev&&(ev.status==='ok'||ev.status==='skipped'))lastOk=j;if(ev&&ev.status==='running')return stages[j];}
7182
+ return stages[Math.min(stages.length-1,lastOk+1)];
7183
+ }
6815
7184
  function renderPipeline(data){
6816
7185
  var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'); if(!host||!summary) return;
6817
7186
  var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;
@@ -6822,16 +7191,37 @@ button.act:disabled{opacity:0.5;cursor:default;}
6822
7191
  ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){
6823
7192
  if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};
6824
7193
  });
6825
- var running=data.overall==='running';
6826
- host.innerHTML=(data.stages||Object.keys(PIPELINE_LABELS)).map(function(stage){
6827
- var ev=latest[stage], status=ev&&ev.status||((running&&stage==='motion_detected')?'running':'pending');
7194
+ var running=data.overall==='running',stages=data.stages||Object.keys(PIPELINE_LABELS),current=pipelineCurrentStage(stages,latest,running);
7195
+ host.innerHTML=stages.map(function(stage,index){
7196
+ var ev=latest[stage],isCurrent=stage===current,status=ev&&ev.status||(isCurrent?'running':'pending');
6828
7197
  var detail=ev&&(ev.message||ev.timestamp)||'waiting';
6829
- if(ev&&ev.timestamp) detail=detail+' · '+new Date(ev.timestamp+'Z').toLocaleTimeString();
6830
- return '<div class="pk-pipe-step '+esc(status)+'"><i></i><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="detail">'+esc(detail)+'</span></div>';
7198
+ var time=ev&&ev.timestamp?new Date(ev.timestamp+'Z').toLocaleString():'';
7199
+ var phase=PIPELINE_PHASES[stage]||'session';
7200
+ return '<div class="pk-pipe-step phase-'+phase+' '+esc(status)+(isCurrent?' current':'')+'"><span class="step-index">'+String(index+1).padStart(2,'0')+'</span><span><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="phase">'+esc(phase)+'</span></span><span class="detail">'+esc(detail)+(time?'<span class="time">'+esc(time)+'</span>':'')+'</span></div>';
6831
7201
  }).join('');
6832
7202
  var blockers=data.blockers||[];
6833
7203
  summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');
6834
- summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?'TEST RUNNING: watch the live camera and stage timeline':'READY: camera-to-speaker remote test can start');
7204
+ summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?('ACTIVE: '+(PIPELINE_LABELS[current]||current||'starting')+' | session '+(sid||'allocating')):'READY: camera-to-speaker remote test can start');
7205
+ var micHealth=$('pk-mic-health');if(micHealth){
7206
+ var micEvent=latest.microphone_published,trackOk=micEvent&&micEvent.status==='ok',inventoryOk=!!prereqs.microphone_ready;
7207
+ micHealth.innerHTML='<div class="pk-mic-check ok"><div class="k">Hardware lock</div><div class="v">USB mic hw:3,0</div></div><div class="pk-mic-check '+(inventoryOk?'ok':'bad')+'"><div class="k">Inventory</div><div class="v">'+(inventoryOk?'detected':'missing')+'</div></div><div class="pk-mic-check '+(trackOk?'ok':micEvent?'bad':'')+'"><div class="k">Production track</div><div class="v">'+esc(micEvent?(micEvent.message||micEvent.status):'waiting for session')+'</div></div>';
7208
+ }
7209
+ }
7210
+ function loadPipelineHistory(n){
7211
+ var host=$('pk-pipeline-history'),pageLabel=$('pk-history-page');if(!host)return;
7212
+ host.innerHTML='<div class="pk-note">Loading session page...</div>';
7213
+ getJson('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-history?page='+pipelineHistoryPage+'&page_size=6'+(QS?'&'+QS.slice(1):''))
7214
+ .then(function(data){
7215
+ pipelineHistoryPage=data.page||1;if(pageLabel)pageLabel.textContent='page '+pipelineHistoryPage+' / '+(data.pages||1)+' | '+(data.total||0)+' runs';
7216
+ var prev=$('pk-history-prev'),next=$('pk-history-next');if(prev)prev.disabled=pipelineHistoryPage<=1;if(next)next.disabled=pipelineHistoryPage>=(data.pages||1);
7217
+ host.innerHTML=(data.items||[]).map(function(run){
7218
+ var events=run.events||[],latest={};events.forEach(function(ev){latest[ev.stage]=ev;});
7219
+ var hasError=Number(run.error_count||0)>0,state=!run.ended_at?'running':hasError?'bad':'ok',label=!run.ended_at?'active':hasError?'attention':(run.end_reason||'complete');
7220
+ var started=run.started_at?new Date(run.started_at+'Z'):null,duration=run.duration_seconds!=null?run.duration_seconds:(started?Math.max(0,Math.floor((Date.now()-started.getTime())/1000)):0);
7221
+ var stageBar=Object.keys(PIPELINE_LABELS).map(function(stage){var ev=latest[stage];return '<i class="'+esc(ev&&ev.status||'pending')+'" title="'+esc(PIPELINE_LABELS[stage])+'"></i>';}).join('');
7222
+ return '<div class="pk-run-card" data-session="'+esc(run.session_id)+'"><div class="pk-run-top"><div><div class="pk-run-title">'+esc(run.session_id)+'</div><div class="pk-run-meta">'+esc(started?started.toLocaleString():'unknown time')+' | '+esc(rpFmtDuration(duration))+' | '+esc(run.transcript_count||0)+' transcript turns | '+esc(run.pipeline_event_count||0)+' events</div></div><span class="pk-run-state '+state+'">'+esc(label)+'</span></div><div class="pk-run-stages">'+stageBar+'</div></div>';
7223
+ }).join('')||'<div class="pk-note">No production sessions recorded for this robot.</div>';
7224
+ }).catch(function(e){host.innerHTML='<div class="pk-note" style="color:var(--bad)">History unavailable: '+esc(e.message)+'</div>';});
6835
7225
  }
6836
7226
  function pollPipeline(n){
6837
7227
  fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})
@@ -7225,7 +7615,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7225
7615
  }).join('');
7226
7616
  host.innerHTML = '<div class="pk-spk-hist-head">last '+list.length+' tests</div>' + rows;
7227
7617
  }
7228
- function _spkTestRenderResult(robotId, result){
7618
+ function _spkTestRenderResult(robotId, result){
7229
7619
  var host = $('pk-spk-test-result'); if(!host) return;
7230
7620
  if(!result){
7231
7621
  host.innerHTML = '';
@@ -7252,9 +7642,32 @@ button.act:disabled{opacity:0.5;cursor:default;}
7252
7642
  + '<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>'
7253
7643
  + '</div>';
7254
7644
  var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
7255
- host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7256
- + banner + meta + diag + '</div>';
7257
- }
7645
+ host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7646
+ + banner + meta + diag + '</div>';
7647
+ }
7648
+ function _micTestRenderResult(result,label){
7649
+ var host=$('pk-mic-test-result'),state=$('pk-mic-test-state');if(!host)return;
7650
+ if(!result||!result.ok){var error=result&&result.error||'robot did not return a result';host.innerHTML='<div class="pk-mic-result bad"><b>TEST FAILED</b><div style="margin-top:.3rem">'+esc(error)+'</div></div>';if(state)state.textContent='Failed | '+error;return;}
7651
+ var passed=result.pass===true,mode=result.mode||label;
7652
+ host.innerHTML='<div class="pk-mic-result '+(passed?'ok':'bad')+'"><b>'+(passed?'MIC PATH VERIFIED':'NO USABLE SIGNAL')+'</b><div class="pk-mic-readings"><div class="pk-mic-reading"><span>Peak</span><b>'+esc(result.recorded_peak_db!=null?result.recorded_peak_db+' dB':'--')+'</b></div><div class="pk-mic-reading"><span>RMS</span><b>'+esc(result.recorded_rms_db!=null?result.recorded_rms_db+' dB':'--')+'</b></div><div class="pk-mic-reading"><span>Nonzero</span><b>'+esc(result.nonzero_percent!=null?result.nonzero_percent+'%':'--')+'</b></div><div class="pk-mic-reading"><span>Format</span><b>'+esc((result.sample_rate||result.input_sample_rate||'--')+' Hz / '+(result.channels||result.input_channels||1)+'ch')+'</b></div></div><div style="margin-top:.38rem;color:var(--muted)">'+esc(result.input_device||'USB microphone')+' | '+esc(result.duration_ms||0)+' ms | '+esc(result.diagnostic||mode)+'</div></div>';
7653
+ if(state)state.textContent=passed?'Passed | '+mode+' confirmed on the robot':'Capture completed but no usable PCM signal was detected';
7654
+ }
7655
+ function setMicTestBusy(busy,text){
7656
+ [$('pk-mic-groundtruth'),$('pk-mic-roundtrip'),$('pk-mic-apply')].forEach(function(button){if(button)button.disabled=busy;});
7657
+ var state=$('pk-mic-test-state');if(state&&text)state.textContent=text;
7658
+ }
7659
+ function runMicGroundtruth(n){
7660
+ var robotId=schedId(n),duration=parseFloat((($('pk-mic-duration')||{}).value)||'4');setMicTestBusy(true,'Recording raw PCM on the robot for '+duration+' seconds...');
7661
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'mic_groundtruth',duration:duration,input:'2',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})
7662
+ .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-groundtruth');})
7663
+ .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'mic_groundtruth');setMicTestBusy(false);});
7664
+ }
7665
+ function runMicRoundtrip(n){
7666
+ var robotId=schedId(n);setMicTestBusy(true,'Playing a reference tone and recording the locked USB microphone...');
7667
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(robotId)+'/shell/speaker-test',{mode:'tone',playback_only:false,frequency:1000,duration:1,amplitude:.6,threshold_db:-30,output:'1',input:'2',output_name:'USB Audio Device: - (hw:2,0)',input_name:'Usb Audio Device: USB Audio (hw:3,0)'})
7668
+ .then(function(q){return _shellWaitForResult(robotId,q.request_id,q.device_id||robotId,'','mic-roundtrip');})
7669
+ .catch(function(e){_micTestRenderResult({ok:false,error:e.message},'roundtrip');setMicTestBusy(false);});
7670
+ }
7258
7671
  function runSpeakerTest(n, forcedMode){
7259
7672
  var robotId = schedId(n), mode=forcedMode||(($('pk-spk-mode')||{}).value||'tts');
7260
7673
  var buttons=[$('pk-spk-runtest'),$('pk-spk-tone'),$('pk-device-test-speaker')];buttons.forEach(function(b){if(b)b.disabled=true;});
@@ -7310,7 +7723,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
7310
7723
  var _origRenderShellResult = _renderShellResult;
7311
7724
  _renderShellResult = function(robotId, result, label){
7312
7725
  _origRenderShellResult(robotId, result, label);
7313
- if(label === 'speaker-test'){
7726
+ if(label === 'speaker-test'){
7314
7727
  _spkTestSetLive(robotId, {live:false});
7315
7728
  _spkTestMark(robotId, result);
7316
7729
  _spkTestRenderResult(robotId, result);
@@ -7323,8 +7736,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
7323
7736
  else { status.textContent = '✗ no signal — '+result.recorded_peak_db+' dB peak'; }
7324
7737
  }
7325
7738
  var btn = $('pk-spk-runtest'); if(btn){ btn.disabled = false; btn.textContent = (($('pk-spk-mode')||{}).value==='tone'?'Run tone test':'Play cached voice'); }
7326
- }
7327
- };
7739
+ }
7740
+ if(label==='mic-groundtruth'||label==='mic-roundtrip'){
7741
+ _micTestRenderResult(result,label);
7742
+ setMicTestBusy(false);
7743
+ }
7744
+ };
7328
7745
 
7329
7746
  function openDrawerForRobot(name){
7330
7747
  var key=String(name||'').toLowerCase(), id='pad:'+name, found=null;