From d2d80bd0d5c3afc31d56e53be6a2636ada8196fa Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 13 Apr 2026 20:31:55 +0200 Subject: [PATCH] docs: describe WS-1 telemetry and tighten UI measurement source --- docs/API.md | 51 ++++++++++++++++++++++++++++++++++++++++ internal/control/ui.html | 9 +++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/API.md b/docs/API.md index ec5ee4b..d827bdf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -234,6 +234,57 @@ Notes: - `clipperLookaheadGain` and `clipperEnvelope` are preferred raw diagnostics; `clipperOrProtectionActive` is only a derived convenience indicator. - `licenseInjectionActive` means chunk-local actual activity, not merely feature presence. - `overNominalEvents` / `overHeadroomEvents` are internal normalized composite envelope counters, not legal overmodulation verdicts. +- `GET /measurements` remains the canonical snapshot/fallback API for live metering. +- `noData` / `stale` are wrapper-level snapshot semantics and must not be inferred solely from the existence of a measurement object. + +### `GET /ws/telemetry` + +WebSocket endpoint for **live metering telemetry**. + +WS-1 scope is intentionally small: +- server → client only +- only `measurement` messages +- no topics +- no bundles +- no runtime multiplexing + +`GET /measurements` stays the canonical snapshot/fallback API. The WebSocket path carries the same latest measurement truth, but in a live transport envelope. + +**Message shape:** +```json +{ + "type": "measurement", + "ts": "2026-04-13T07:00:53.842Z", + "seq": 128, + "data": { + "timestamp": "2026-04-13T07:00:53.842Z", + "sampleRateHz": 228000, + "chunkSamples": 11400, + "chunkDurationMs": 50, + "sequence": 128, + "flags": { + "stereoEnabled": true, + "stereoMode": "DSB" + }, + "lrPreEncodePostWatermark": { + "lRms": 0.27, + "rRms": 0.27, + "lPeakAbs": 0.51, + "rPeakAbs": 0.51 + }, + "compositeFinalPreIq": { + "peakAbs": 0.63, + "pilotInjectionEquivalentPercent": 9.0 + } + } +} +``` + +Notes: +- `data` is intended to be semantically identical to the `measurement` object returned by `GET /measurements`. +- Transport metadata such as `type`, `ts`, and `seq` exists only in the WS envelope. +- On connect, the server may immediately send the latest known measurement snapshot so the client does not have to wait for the next natural update. +- Slow clients are best-effort only; the server may drop stale telemetry frames and keep the newest state. --- diff --git a/internal/control/ui.html b/internal/control/ui.html index 5808331..453a09a 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -1032,7 +1032,7 @@ function normState(s){return(typeof s==='string'?s.trim().toLowerCase():'')||'id function stateSev(s){switch(normState(s)){case 'running':return'ok';case 'degraded':case 'muted':return'warn';case 'faulted':return'err';default:return'info';}} function stateClass(s){switch(normState(s)){case 'faulted':return'err';case 'muted':case 'degraded':case 'prebuffering':case 'arming':case 'stopping':case 'idle':return'warn';default:return'good';}} function flowSeverityFromRuntime(){const rt=S.server.runtime||{},eng=rt.engine||{},ing=rt.ingest||{},src=ing.source||{},ir=ing.runtime||{};if(normState(eng.state)==='faulted')return{sev:'err',title:'Fault',text:eng.lastError||eng.runtimeAlert||'TX faulted'};if(eng.runtimeAlert)return{sev:'warn',title:'Runtime warning',text:String(eng.runtimeAlert)};if(ir.writeBlocked)return{sev:'err',title:'Ingest blocked',text:'Ingest runtime is write-blocked'};if(src.state&&String(src.state).toLowerCase()==='reconnecting')return{sev:'warn',title:'Source reconnecting',text:'Input source is reconnecting'};return null;} -function flowNodeData(){const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{},ing=rt.ingest||{},src=ing.source||{},ir=ing.runtime||{},active=ing.active||{},rds=cfg.rds||{},fm=cfg.fm||{},audio=cfg.audio||{};const txState=normState(eng.state||measWrap.state);const queueHealth=String(eng.queue?.health||measWrap.queue?.health||'').toLowerCase();const sourceKind=String(active.kind||cfg.ingest?.kind||'none');const sourceState=String(src.state||'').toLowerCase();const sourceHost=(()=>{const ep=String(active.origin?.endpoint||'');try{return ep?new URL(ep).host:active.origin?.streamName||'--';}catch{return active.origin?.streamName||ep||ep||'--';}})(); +function flowNodeData(){const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},measWrap=S.server.measurements||{},meas=currentMeasurement(),ing=rt.ingest||{},src=ing.source||{},ir=ing.runtime||{},active=ing.active||{},rds=cfg.rds||{},fm=cfg.fm||{},audio=cfg.audio||{};const txState=normState(eng.state||measWrap.state);const queueHealth=String(eng.queue?.health||measWrap.queue?.health||'').toLowerCase();const sourceKind=String(active.kind||cfg.ingest?.kind||'none');const sourceState=String(src.state||'').toLowerCase();const sourceHost=(()=>{const ep=String(active.origin?.endpoint||'');try{return ep?new URL(ep).host:active.origin?.streamName||'--';}catch{return active.origin?.streamName||ep||ep||'--';}})(); return { source:{state:sourceKind==='none'?'idle':(sourceState==='reconnecting'?'warn':(src.connected===false?'err':'good')),sub:sourceKind.toUpperCase(),detail:(sourceKind==='none'?'No source':(src.connected===false?'Disconnected':'Connected')),lines:[`Kind: ${sourceKind}`,`Origin: ${active.origin?.endpoint||active.origin?.streamName||'--'}`,`State: ${src.state||'idle'}`,`Reconnects: ${src.reconnects??0}`],applyMode:'reload'}, ingest:{state:ir.writeBlocked?'err':(String(ir.state||'').toLowerCase()==='degraded'||sourceState==='reconnecting'?'warn':(sourceKind==='none'?'idle':'good')),sub:(ir.state||src.state||'idle').toUpperCase(),detail:joinParts([isFinite(Number(src.bufferedSeconds))?`${Number(src.bufferedSeconds).toFixed(1)} s buffer`:'',ir.prebuffering?'prebuffer':'']).trim()||'Stable',lines:[`Runtime: ${ir.state||'--'}`,`Buffered: ${isFinite(Number(src.bufferedSeconds))?Number(src.bufferedSeconds).toFixed(2)+'s':'--'}`,`Last chunk: ${ageFrom(ir.lastChunkAt||src.lastChunkAt)}`,`Write blocked: ${ir.writeBlocked?'yes':'no'}`],applyMode:'reload'}, @@ -1087,6 +1087,7 @@ function fmtPTY(v){const n=Number(v);return(n>=0&&n<=31)?`${n} — ${PTY_NAMES[n function ageStr(ts){if(!ts)return'--';const d=Math.max(0,Math.floor((Date.now()-ts)/1000));if(d<2)return'just now';if(d<60)return d+'s ago';const m=Math.floor(d/60);if(m<60)return m+'m ago';return Math.floor(m/60)+'h ago';} function ageFrom(v){if(!v)return'--';if(typeof v==='number')return ageStr(v);const ts=Date.parse(String(v));return isNaN(ts)?'--':ageStr(ts);} function joinParts(ps){return ps.filter(p=>String(p||'').trim()!=='').join(' · ');} +function currentMeasurement(){const wrap=S.server.measurements||{};if(wrap.measurement)return wrap.measurement;const legacy=S.server.runtime?.engine?.measurement;return legacy||{};} // ── DOM helpers ──────────────────────────────────────────────────────────── function setText(id,text){const el=$(id);if(el){const s=text==null?'--':String(text);if(el.textContent!==s)el.textContent=s;}} @@ -1162,7 +1163,7 @@ function render(){try{_render();}catch(e){console.error('[render]',e);}} function renderMetersOnly(){try{renderMetersSection();}catch(e){console.error('[renderMetersOnly]',e);}} function renderMetersSection(){ - const rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{}; + const rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},measWrap=S.server.measurements||{},meas=currentMeasurement(); const txSt=normState(eng.state); const urN=Number(eng.underruns??drv.underruns??0); const txR=txSt==='running'?1:S.txBusy?.55:.08; @@ -1186,7 +1187,7 @@ function renderMetersSection(){ } function renderFlowLiveBits(){ - const rt=S.server.runtime||{},eng=rt.engine||{},ing=rt.ingest||{},measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{}; + const rt=S.server.runtime||{},eng=rt.engine||{},ing=rt.ingest||{},measWrap=S.server.measurements||{},meas=currentMeasurement(); const masterState=String((eng.state||'idle')).toUpperCase(); const issue=flowSeverityFromRuntime(); const runtimeAge=Number(eng.runtimeStateDurationSeconds); @@ -1201,7 +1202,7 @@ function renderFlowLiveBits(){ } function _render(){ - 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||{}; + 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=currentMeasurement(); // ── Header setText('badge-backend',cfg.backend?.kind||cfg.backend||'--');