| @@ -168,7 +168,9 @@ button,input,select{font:inherit}button{user-select:none} | |||
| .hifi-label{font-size:11px;font-weight:700;color:var(--text)} | |||
| .hifi-value{font-size:11px;font-weight:700;color:var(--text-muted);text-align:right;font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1;white-space:nowrap} | |||
| .hifi-meter{position:relative;height:12px;border-radius:999px;background:#e7ebf0;border:1px solid var(--border);overflow:hidden} | |||
| .hifi-fill{position:absolute;left:0;top:0;bottom:0;width:0%;background:linear-gradient(90deg,#35b26f 0%, #d8b14a 72%, #d95f5f 100%)} | |||
| .hifi-fill{position:absolute;left:0;top:0;bottom:0;width:0%;background:linear-gradient(90deg,#35b26f 0%, #4fd37f 100%)} | |||
| .hifi-fill.warn{background:linear-gradient(90deg,#d0a13b 0%, #e1bb61 100%)} | |||
| .hifi-fill.err{background:linear-gradient(90deg,#cf4f4f 0%, #e07171 100%)} | |||
| .hifi-peak{position:absolute;top:-1px;bottom:-1px;width:2px;background:#111827;opacity:.85;transform:translateX(-1px)} | |||
| /* Panels */ | |||
| .panel{overflow:hidden} | |||
| @@ -399,8 +401,8 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1 | |||
| <div class="hifi-grid"> | |||
| <div class="hifi-card"> | |||
| <div class="hifi-title">Audio RMS</div> | |||
| <div class="hifi-row"><div class="hifi-label">L</div><div class="hifi-meter"><div class="hifi-fill" id="audio-l-rms-fill"></div></div><div class="hifi-value" id="audio-l-rms-text">--</div></div> | |||
| <div class="hifi-row"><div class="hifi-label">R</div><div class="hifi-meter"><div class="hifi-fill" id="audio-r-rms-fill"></div></div><div class="hifi-value" id="audio-r-rms-text">--</div></div> | |||
| <div class="hifi-row"><div class="hifi-label">L</div><div class="hifi-meter"><div class="hifi-fill" id="audio-l-rms-fill"></div><div class="hifi-peak" id="audio-l-rms-marker"></div></div><div class="hifi-value" id="audio-l-rms-text">--</div></div> | |||
| <div class="hifi-row"><div class="hifi-label">R</div><div class="hifi-meter"><div class="hifi-fill" id="audio-r-rms-fill"></div><div class="hifi-peak" id="audio-r-rms-marker"></div></div><div class="hifi-value" id="audio-r-rms-text">--</div></div> | |||
| </div> | |||
| <div class="hifi-card"> | |||
| <div class="hifi-title">Audio Peak</div> | |||
| @@ -1069,6 +1071,10 @@ function renderFlowPopover(){const pop=$('flow-popover');if(!pop)return;if(!S.fl | |||
| function fmt(n){if(n==null)return'--';if(n>=1e9)return(n/1e9).toFixed(2)+'G';if(n>=1e6)return(n/1e6).toFixed(2)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} | |||
| function fmtTime(s){if(!s||s<=0)return'--';const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=Math.floor(s%60);if(h>0)return`${h}h ${m}m`;if(m>0)return`${m}m ${sec}s`;return`${sec}s`;} | |||
| function fmtPct(v){return typeof v==='number'?(v*100).toFixed(0)+'%':'--';} | |||
| function ampToDb(v,floor=-60){if(!(typeof v==='number')||!isFinite(v)||v<=0)return floor;return Math.max(floor,20*Math.log10(v));} | |||
| function dbToRatio(db,floor=-60,ceil=0){if(!(typeof db==='number')||!isFinite(db))return 0;return Math.max(0,Math.min(1,(db-floor)/(ceil-floor)));} | |||
| function fmtDbfs(v){if(!(typeof v==='number')||!isFinite(v)||v<=0)return'−∞ dB';const db=20*Math.log10(v);return `${db.toFixed(1)} dB`;} | |||
| function fmtModPct(v){if(!(typeof v==='number')||!isFinite(v))return'--';return `${(v*100).toFixed(0)}%`;} | |||
| function fmtFreq(v){return typeof v==='number'?v.toFixed(1)+' MHz':'--';} | |||
| function fmtDur(v){if(!isFinite(v)||v<0)return'--';return v>=1?v.toFixed(2)+'s':(v*1000).toFixed(0)+'ms';} | |||
| function fmtPilot(v){return typeof v==='number'?(v*100).toFixed(1)+'%':'--';} | |||
| @@ -1085,7 +1091,8 @@ function setCls(id,c){const el=$(id);if(el)el.className=c;} | |||
| function setMeter(fid,tid,ratio,text,mode='good'){const f=$(fid);if(!f)return;f.style.width=Math.max(0,Math.min(100,Math.round((ratio??0)*100)))+'%';f.className='meter-fill'+(mode==='warn'?' warn':mode==='err'?' err':'');setText(tid,text);} | |||
| function drawSpark(svgId,vals,mode='good',maxO=null){const svg=$(svgId);if(!svg)return;svg.setAttribute('class',`spark ${mode}`);const W=160,H=34,pts=vals.length?vals:[0,0],mx=maxO!=null?maxO:Math.max(...pts,1),step=pts.length<=1?W:W/(pts.length-1);const coords=pts.map((v,i)=>{const x=i*step,y=H-4-((v-0)/(mx||1))*(H-8);return[x,y];});const line=coords.map(([x,y],i)=>`${i===0?'M':'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');svg.innerHTML=`<path class="area" d="${line} L${W},${H} L0,${H} Z"></path><path class="line" d="${line}"></path>`;} | |||
| function updateHoldMeter(state,targetRms,targetPeak,{attackPerSec=6.0,releasePerSec=1.4,rmsHoldMs=1400,rmsHoldReleasePerSec=0.12,peakReleasePerSec=1.1,holdMs=900,holdReleasePerSec=0.35,textRmsAttackPerSec=2.2,textRmsReleasePerSec=0.22,textPeakAttackPerSec=3.0,textPeakReleasePerSec=0.16,textRmsLatchMs=220,textPeakLatchMs=220,textRmsStep=0.01,textPeakStep=0.01,textRmsHysteresis=0.015,textPeakHysteresis=0.015}={}){const now=performance.now();const dt=Math.max(0.001,Math.min(0.25,state.lastTs?((now-state.lastTs)/1000):0.05));state.lastTs=now;const approach=(cur,target,upRate,downRate)=>{if(target>cur)return Math.min(target,cur+upRate*dt);return Math.max(target,cur-downRate*dt);};const quantize=(v,step)=>step>0?Math.round(v/step)*step:v;state.rms=approach(state.rms,targetRms,attackPerSec,releasePerSec);if(state.rms>=state.rmsHold){state.rmsHold=state.rms;state.rmsHoldTimerMs=rmsHoldMs;}else if(state.rmsHoldTimerMs>0){state.rmsHoldTimerMs=Math.max(0,state.rmsHoldTimerMs-dt*1000);}else{state.rmsHold=Math.max(state.rms,state.rmsHold-rmsHoldReleasePerSec*dt);}state.peak=approach(state.peak,targetPeak,attackPerSec*1.4,peakReleasePerSec);if(targetPeak>=state.hold){state.hold=targetPeak;state.holdTimerMs=holdMs;}else if(state.holdTimerMs>0){state.holdTimerMs=Math.max(0,state.holdTimerMs-dt*1000);}else{state.hold=Math.max(state.peak,state.hold-holdReleasePerSec*dt);}state.textRms=approach(state.textRms,state.rmsHold,textRmsAttackPerSec,textRmsReleasePerSec);state.textPeak=approach(state.textPeak,state.hold,textPeakAttackPerSec,textPeakReleasePerSec);state.textRmsLatchMs=Math.max(0,(state.textRmsLatchMs||0)-dt*1000);state.textPeakLatchMs=Math.max(0,(state.textPeakLatchMs||0)-dt*1000);const nextTextRms=quantize(state.textRms,textRmsStep);const nextTextPeak=quantize(state.textPeak,textPeakStep);if(state.textRmsLatchMs<=0&&Math.abs(nextTextRms-(state.textRmsDisplay||0))>=textRmsHysteresis){state.textRmsDisplay=nextTextRms;state.textRmsLatchMs=textRmsLatchMs;}if(state.textPeakLatchMs<=0&&Math.abs(nextTextPeak-(state.textPeakDisplay||0))>=textPeakHysteresis){state.textPeakDisplay=nextTextPeak;state.textPeakLatchMs=textPeakLatchMs;}return state;} | |||
| function renderHifiMeter(fillId,peakId,textId,value,hold,text,scale=1){const fill=$(fillId),peak=$(peakId);if(fill)fill.style.width=`${Math.max(0,Math.min(100,(value/scale)*100))}%`;if(peak)peak.style.left=`${Math.max(0,Math.min(100,(hold/scale)*100))}%`;setText(textId,text);} | |||
| function renderHifiMeter(fillId,peakId,textId,value,hold,text,scale=1,mode='good'){const fill=$(fillId),peak=$(peakId);if(fill){fill.style.width=`${Math.max(0,Math.min(100,(value/scale)*100))}%`;fill.className='hifi-fill'+(mode==='warn'?' warn':mode==='err'?' err':'');}if(peak)peak.style.left=`${Math.max(0,Math.min(100,(hold/scale)*100))}%`;setText(textId,text);} | |||
| function setHifiScale(title,labels){const cards=[...document.querySelectorAll('.hifi-card')];const card=cards.find(c=>c.querySelector('.hifi-title')?.textContent?.trim()===title);if(!card)return;let scale=card.querySelector('.hifi-scale');if(!scale){scale=document.createElement('div');scale.className='hifi-scale';scale.style.display='flex';scale.style.justifyContent='space-between';scale.style.marginTop='8px';scale.style.fontSize='10px';scale.style.color='var(--text-muted)';scale.style.fontVariantNumeric='tabular-nums';card.appendChild(scale);}scale.innerHTML=labels.map(s=>`<span>${s}</span>`).join('');} | |||
| function syncSlider(sid,did,key,fmt2=v=>v==null?'--':Number(v).toFixed(2)){const sl=$(sid);if(!sl)return;const n=cfgEff(key);if(document.activeElement!==sl&&n!=null)sl.value=String(Number(n));setText(did,fmt2(n));} | |||
| function syncToggle(tid,lid,key){const on=!!srvVal(key),busy=!!S.toggleBusy[key];const el=$(tid);if(!el)return;el.className='toggle'+(on?' on':'')+(busy?' busy':'');el.setAttribute('aria-checked',on?'true':'false');setText(lid,busy?'WAIT':(on?'ON':'OFF'));} | |||
| @@ -1150,7 +1157,7 @@ async function saveIngest(){if(S.ingestSaving)return;if(!S.ingestDirty){toast('N | |||
| function render(){try{_render();}catch(e){console.error('[render]',e);}} | |||
| function _render(){ | |||
| const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null,measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{}; | |||
| const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null,ing=rt.ingest||{},ingRt=ing.runtime||{},measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{}; | |||
| // ── Header | |||
| setText('badge-backend',cfg.backend?.kind||cfg.backend||'--'); | |||
| @@ -1182,7 +1189,7 @@ function _render(){ | |||
| $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0; | |||
| // ── Overview: meters + sparklines | |||
| if(aud&&typeof aud.buffered==='number'){const r=Math.max(0,Math.min(1,aud.buffered));setMeter('meter-audio-fill','meter-audio-text',r,fmtPct(r),r<.05?'err':r<.2?'warn':'good');}else setMeter('meter-audio-fill','meter-audio-text',0,'N/A','warn'); | |||
| if(typeof ingRt.buffered==='number'||typeof ingRt.bufferedSeconds==='number'){const pct=Math.max(0,Math.min(1,Number(ingRt.buffered)||0));const secs=Math.max(0,Number(ingRt.bufferedSeconds)||0);const ratio=Math.max(0,Math.min(1,secs/1.5));const text=`${secs.toFixed(2)} s · ${fmtPct(pct)}`;const mode=secs<0.25?'err':secs<0.75?'warn':'good';setMeter('meter-audio-fill','meter-audio-text',ratio,text,mode);}else if(aud&&typeof aud.buffered==='number'){const r=Math.max(0,Math.min(1,aud.buffered));setMeter('meter-audio-fill','meter-audio-text',r,fmtPct(r),r<.05?'err':r<.2?'warn':'good');}else setMeter('meter-audio-fill','meter-audio-text',0,'N/A','warn'); | |||
| const urN=Number(eng.underruns??drv.underruns??0); | |||
| setMeter('meter-stream-fill','meter-stream-text',urN<=0?1:Math.max(0,1-Math.min(urN,10)/10),urN===0?'Clean':`${urN} underrun${urN===1?'':'s'}`,urN===0?'good':urN<3?'warn':'err'); | |||
| const txR=txSt==='running'?1:S.txBusy?.55:.08; | |||
| @@ -1190,11 +1197,17 @@ function _render(){ | |||
| const audioL=updateHoldMeter(S.meterState.audioL,Number(meas.lrPreEncodePostWatermark?.lRms||0),Number(meas.lrPreEncodePostWatermark?.lPeakAbs||0),{attackPerSec:7.0,releasePerSec:0.6,rmsHoldMs:1700,rmsHoldReleasePerSec:0.10,peakReleasePerSec:0.45,holdMs:1700,holdReleasePerSec:0.14,textRmsAttackPerSec:1.0,textRmsReleasePerSec:0.06,textPeakAttackPerSec:1.6,textPeakReleasePerSec:0.07,textRmsLatchMs:260,textPeakLatchMs:260,textRmsStep:0.01,textPeakStep:0.01,textRmsHysteresis:0.01,textPeakHysteresis:0.01}); | |||
| const audioR=updateHoldMeter(S.meterState.audioR,Number(meas.lrPreEncodePostWatermark?.rRms||0),Number(meas.lrPreEncodePostWatermark?.rPeakAbs||0),{attackPerSec:7.0,releasePerSec:0.6,rmsHoldMs:1700,rmsHoldReleasePerSec:0.10,peakReleasePerSec:0.45,holdMs:1700,holdReleasePerSec:0.14,textRmsAttackPerSec:1.0,textRmsReleasePerSec:0.06,textPeakAttackPerSec:1.6,textPeakReleasePerSec:0.07,textRmsLatchMs:260,textPeakLatchMs:260,textRmsStep:0.01,textPeakStep:0.01,textRmsHysteresis:0.01,textPeakHysteresis:0.01}); | |||
| const mpx=updateHoldMeter(S.meterState.mpx,0,Number(meas.compositeFinalPreIq?.peakAbs||0),{attackPerSec:8.0,releasePerSec:0.4,peakReleasePerSec:0.375,holdMs:2200,holdReleasePerSec:0.09,textPeakAttackPerSec:1.4,textPeakReleasePerSec:0.06,textPeakLatchMs:320,textPeakStep:0.01,textPeakHysteresis:0.01}); | |||
| renderHifiMeter('audio-l-rms-fill',null,'audio-l-rms-text',audioL.rms,0,typeof meas.lrPreEncodePostWatermark?.lRms==='number'?audioL.textRmsDisplay.toFixed(2):'--'); | |||
| renderHifiMeter('audio-r-rms-fill',null,'audio-r-rms-text',audioR.rms,0,typeof meas.lrPreEncodePostWatermark?.rRms==='number'?audioR.textRmsDisplay.toFixed(2):'--'); | |||
| renderHifiMeter('audio-l-peak-fill','audio-l-peak-marker','audio-l-peak-text',audioL.peak,audioL.hold,typeof meas.lrPreEncodePostWatermark?.lPeakAbs==='number'?audioL.textPeakDisplay.toFixed(2):'--'); | |||
| renderHifiMeter('audio-r-peak-fill','audio-r-peak-marker','audio-r-peak-text',audioR.peak,audioR.hold,typeof meas.lrPreEncodePostWatermark?.rPeakAbs==='number'?audioR.textPeakDisplay.toFixed(2):'--'); | |||
| renderHifiMeter('mpx-peak-fill','mpx-peak-marker','mpx-peak-text',mpx.peak,mpx.hold,typeof meas.compositeFinalPreIq?.peakAbs==='number'?mpx.textPeakDisplay.toFixed(2):'--',1.1); | |||
| const audioLRmsDb=ampToDb(audioL.rms),audioRRmsDb=ampToDb(audioR.rms),audioLTextRmsDb=ampToDb(audioL.textRmsDisplay),audioRTextRmsDb=ampToDb(audioR.textRmsDisplay),audioLPeakDb=ampToDb(audioL.peak),audioRPeakDb=ampToDb(audioR.peak),audioLHoldDb=ampToDb(audioL.hold),audioRHoldDb=ampToDb(audioR.hold),audioLTextPeakDb=ampToDb(audioL.textPeakDisplay),audioRTextPeakDb=ampToDb(audioR.textPeakDisplay); | |||
| const audioMode=(db)=>(db>=-6?'err':db>=-12?'warn':'good'); | |||
| const mpxMode=(v)=>(v>1.10?'err':v>1.00?'warn':'good'); | |||
| renderHifiMeter('audio-l-rms-fill','audio-l-rms-marker','audio-l-rms-text',dbToRatio(audioLRmsDb),dbToRatio(ampToDb(audioL.rmsHold)),typeof meas.lrPreEncodePostWatermark?.lRms==='number'?`${audioLTextRmsDb.toFixed(1)} dB`:'--',1,audioMode(audioLRmsDb)); | |||
| renderHifiMeter('audio-r-rms-fill','audio-r-rms-marker','audio-r-rms-text',dbToRatio(audioRRmsDb),dbToRatio(ampToDb(audioR.rmsHold)),typeof meas.lrPreEncodePostWatermark?.rRms==='number'?`${audioRTextRmsDb.toFixed(1)} dB`:'--',1,audioMode(audioRRmsDb)); | |||
| renderHifiMeter('audio-l-peak-fill','audio-l-peak-marker','audio-l-peak-text',dbToRatio(audioLPeakDb),dbToRatio(audioLHoldDb),typeof meas.lrPreEncodePostWatermark?.lPeakAbs==='number'?`${audioLTextPeakDb.toFixed(1)} dB`:'--',1,audioMode(audioLPeakDb)); | |||
| renderHifiMeter('audio-r-peak-fill','audio-r-peak-marker','audio-r-peak-text',dbToRatio(audioRPeakDb),dbToRatio(audioRHoldDb),typeof meas.lrPreEncodePostWatermark?.rPeakAbs==='number'?`${audioRTextPeakDb.toFixed(1)} dB`:'--',1,audioMode(audioRPeakDb)); | |||
| renderHifiMeter('mpx-peak-fill','mpx-peak-marker','mpx-peak-text',mpx.peak,mpx.hold,typeof meas.compositeFinalPreIq?.peakAbs==='number'?fmtModPct(mpx.textPeakDisplay):'--',1.5,mpxMode(mpx.peak)); | |||
| setHifiScale('Audio RMS',['−60 dB','−24 dB','−12 dB','−6 dB','0 dB']); | |||
| setHifiScale('Audio Peak',['−60 dB','−24 dB','−12 dB','−6 dB','0 dB']); | |||
| setHifiScale('MPX Peak',['0%','50%','100%','150%']); | |||
| drawSpark('spark-audio',S.charts.audio,'good',1); | |||
| drawSpark('spark-underruns',S.charts.underruns,urN>0?'err':'warn'); | |||
| drawSpark('spark-tx',S.charts.tx,txSt==='running'?'good':'warn',1); | |||