diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 71c8bd3..b241a6a 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -351,6 +351,7 @@ func (b *txBridge) TXStats() map[string]any { "appliedFrequencyMHz": s.AppliedFrequencyMHz, "activePS": s.ActivePS, "activeRadioText": s.ActiveRadioText, + "measurement": s.Measurement, "degradedTransitions": s.DegradedTransitions, "mutedTransitions": s.MutedTransitions, "faultedTransitions": s.FaultedTransitions, diff --git a/docs/API.md b/docs/API.md index 50e5b51..ec5ee4b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -57,7 +57,7 @@ Current transmitter status (read-only snapshot). Runtime indicator, alert, and q ### `GET /runtime` -Live engine and driver telemetry. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under `ingest`. +Live engine and driver telemetry. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under `ingest`. The engine payload may also include the latest measurement snapshot under `engine.measurement` for convenience, but high-frequency metering clients should prefer `/measurements`. **Response:** ```json @@ -152,6 +152,89 @@ Live engine and driver telemetry. When ingest runtime is configured, this endpoi `controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload. +--- + +### `GET /measurements` + +High-frequency live metering snapshot for the multiplex chain. This endpoint is intended for Overview/Flow signal metering and should be preferred over polling `/runtime` at high rate. + +**Response when no current snapshot is available:** +```json +{ + "noData": true, + "stale": true +} +``` + +**Response when a snapshot is available:** +```json +{ + "noData": false, + "stale": false, + "measurement": { + "timestamp": "2026-04-13T05:30:00Z", + "sampleRateHz": 228000, + "chunkSamples": 11400, + "chunkDurationMs": 50, + "sequence": 12345, + "flags": { + "stereoEnabled": true, + "stereoMode": "DSB", + "rdsEnabled": true, + "rds2Enabled": false, + "bs412Enabled": true, + "compositeClipperEnabled": true, + "watermarkEnabled": false, + "licenseInjectionActive": false + }, + "lrPreEncodePostWatermark": { + "lRms": 0.41, + "rRms": 0.39, + "lPeakAbs": 0.98, + "rPeakAbs": 0.96, + "lrBalanceDb": 0.42, + "lClipEvents": 12, + "rClipEvents": 8 + }, + "audioMpxPreBs412": { + "rms": 0.52, + "peakAbs": 1.0, + "monoRms": 0.34, + "stereoRms": 0.18, + "crestFactor": 1.92, + "clipperLookaheadGain": 0.94, + "clipperEnvelope": 1.03, + "clipperOrProtectionActive": true + }, + "audioMpxPostBs412": { + "rms": 0.46, + "peakAbs": 0.91, + "bs412GainApplied": 0.88, + "bs412AttenuationDb": -1.11, + "estimatedAudioPower": 0.21 + }, + "compositeFinalPreIq": { + "rms": 0.49, + "peakAbs": 1.08, + "pilotRms": 0.064, + "pilotPeakAbs": 0.09, + "pilotInjectionEquivalentPercent": 9.0, + "rdsRms": 0.028, + "rdsPeakAbs": 0.04, + "overNominalEvents": 91, + "overHeadroomEvents": 0 + } + } +} +``` + +Notes: +- `pilotInjectionEquivalentPercent` is an operator-facing derived value and is separate from the raw `pilotRms` field. +- `rdsInjectionEquivalentPercent` is intentionally not exposed yet in MVP until its derivation is mathematically fixed. +- `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. + --- ### `POST /runtime/fault/reset` diff --git a/internal/app/engine.go b/internal/app/engine.go index 6d1cb02..f307099 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -71,33 +71,34 @@ func durationMs(ns uint64) float64 { } type EngineStats struct { - State string `json:"state"` - RuntimeStateDurationSeconds float64 `json:"runtimeStateDurationSeconds"` - ChunksProduced uint64 `json:"chunksProduced"` - TotalSamples uint64 `json:"totalSamples"` - Underruns uint64 `json:"underruns"` - LateBuffers uint64 `json:"lateBuffers,omitempty"` - LastError string `json:"lastError,omitempty"` - UptimeSeconds float64 `json:"uptimeSeconds"` - MaxCycleMs float64 `json:"maxCycleMs,omitempty"` - MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"` - MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"` - MaxWriteMs float64 `json:"maxWriteMs,omitempty"` - MaxQueueResidenceMs float64 `json:"maxQueueResidenceMs,omitempty"` - MaxPipelineLatencyMs float64 `json:"maxPipelineLatencyMs,omitempty"` - Queue output.QueueStats `json:"queue"` - RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` - RuntimeAlert string `json:"runtimeAlert,omitempty"` - AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` - ActivePS string `json:"activePS,omitempty"` - ActiveRadioText string `json:"activeRadioText,omitempty"` - LastFault *FaultEvent `json:"lastFault,omitempty"` - DegradedTransitions uint64 `json:"degradedTransitions"` - MutedTransitions uint64 `json:"mutedTransitions"` - FaultedTransitions uint64 `json:"faultedTransitions"` - FaultCount uint64 `json:"faultCount"` - FaultHistory []FaultEvent `json:"faultHistory,omitempty"` - TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"` + State string `json:"state"` + RuntimeStateDurationSeconds float64 `json:"runtimeStateDurationSeconds"` + ChunksProduced uint64 `json:"chunksProduced"` + TotalSamples uint64 `json:"totalSamples"` + Underruns uint64 `json:"underruns"` + LateBuffers uint64 `json:"lateBuffers,omitempty"` + LastError string `json:"lastError,omitempty"` + UptimeSeconds float64 `json:"uptimeSeconds"` + MaxCycleMs float64 `json:"maxCycleMs,omitempty"` + MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"` + MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"` + MaxWriteMs float64 `json:"maxWriteMs,omitempty"` + MaxQueueResidenceMs float64 `json:"maxQueueResidenceMs,omitempty"` + MaxPipelineLatencyMs float64 `json:"maxPipelineLatencyMs,omitempty"` + Queue output.QueueStats `json:"queue"` + RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"` + RuntimeAlert string `json:"runtimeAlert,omitempty"` + AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"` + ActivePS string `json:"activePS,omitempty"` + ActiveRadioText string `json:"activeRadioText,omitempty"` + Measurement *offpkg.MeasurementSnapshot `json:"measurement,omitempty"` + LastFault *FaultEvent `json:"lastFault,omitempty"` + DegradedTransitions uint64 `json:"degradedTransitions"` + MutedTransitions uint64 `json:"mutedTransitions"` + FaultedTransitions uint64 `json:"faultedTransitions"` + FaultCount uint64 `json:"faultCount"` + FaultHistory []FaultEvent `json:"faultHistory,omitempty"` + TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"` } type RuntimeIndicator string @@ -530,6 +531,7 @@ func (e *Engine) Stats() EngineStats { AppliedFrequencyMHz: e.appliedFrequencyMHz(), ActivePS: activePS, ActiveRadioText: activeRT, + Measurement: e.generator.LatestMeasurement(), LastFault: lastFault, DegradedTransitions: e.degradedTransitions.Load(), MutedTransitions: e.mutedTransitions.Load(), diff --git a/internal/control/control.go b/internal/control/control.go index 2c765e1..b807c98 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -289,6 +289,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/config", s.handleConfig) mux.HandleFunc("/config/ingest/save", s.handleIngestSave) mux.HandleFunc("/runtime", s.handleRuntime) + mux.HandleFunc("/measurements", s.handleMeasurements) mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) mux.HandleFunc("/tx/start", s.handleTXStart) mux.HandleFunc("/tx/stop", s.handleTXStop) @@ -355,6 +356,38 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(status) } +func (s *Server) handleMeasurements(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + tx := s.tx + s.mu.RUnlock() + + result := map[string]any{"noData": true, "stale": true} + if tx != nil { + if stats := tx.TXStats(); stats != nil { + if measurement, ok := stats["measurement"]; ok && measurement != nil { + result = map[string]any{"noData": false, "stale": false, "measurement": measurement} + if state, ok := stats["state"]; ok { + result["state"] = state + } + if applied, ok := stats["appliedFrequencyMHz"]; ok { + result["appliedFrequencyMHz"] = applied + } + if queue, ok := stats["queue"]; ok { + result["queue"] = queue + } + if runtimeIndicator, ok := stats["runtimeIndicator"]; ok { + result["runtimeIndicator"] = runtimeIndicator + } + if runtimeAlert, ok := stats["runtimeAlert"]; ok { + result["runtimeAlert"] = runtimeAlert + } + } + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} + func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() drv := s.drv diff --git a/internal/control/ui.html b/internal/control/ui.html index a0c3402..4aa1c3f 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -159,6 +159,17 @@ button,input,select{font:inherit}button{user-select:none} .spark.good path.line{stroke:var(--green)}.spark.good path.area{fill:var(--green)} .spark.warn path.line{stroke:var(--amber)}.spark.warn path.area{fill:var(--amber)} .spark.err path.line{stroke:var(--accent)}.spark.err path.area{fill:var(--accent)} +/* Hi-fi meters */ +.hifi-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px} +.hifi-card{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2);min-height:96px} +.hifi-title{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px} +.hifi-row{display:grid;grid-template-columns:26px 1fr 64px;gap:10px;align-items:center;margin-bottom:8px;min-height:20px} +.hifi-row:last-child{margin-bottom:0} +.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-peak{position:absolute;top:-1px;bottom:-1px;width:2px;background:#111827;opacity:.85;transform:translateX(-1px)} /* Panels */ .panel{overflow:hidden} .panel-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--border);background:var(--surface2);cursor:pointer;user-select:none} @@ -385,6 +396,22 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
Stream Health
--
TX Activity
--
+
+
+
Audio RMS
+
L
--
+
R
--
+
+
+
Audio Peak
+
L
--
+
R
--
+
+
+
MPX Peak
+
MPX
--
+
+
@@ -883,7 +910,7 @@ const mobileMq=window.matchMedia('(max-width:640px)'); let toastTimer=null; const S={ - server:{config:null,runtime:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0}, + server:{config:null,runtime:null,measurements:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0,lastMeasurementsAt:0}, lastRTState:'',draft:{},errors:{},dirty:new Set(), fieldErrors:{}, flowSelected:null,flowHover:null,flowAnchor:null, @@ -893,6 +920,7 @@ const S={ pollersStarted:false,mobilePanelsApplied:false,freqPresetIndex:0, charts:{audio:[],underruns:[],tx:[],hw:[],qf:[]}, transitions:[], + meterState:{audioL:{rms:0,peak:0,hold:0},audioR:{rms:0,peak:0,hold:0},mpx:{rms:0,peak:0,hold:0}}, }; // ── Field definitions ────────────────────────────────────────────────────── @@ -983,6 +1011,7 @@ async function api(path,opts){const r=await fetch(path,opts);const t=await r.tex function setConn(ok,label){const led=$('led-conn'),lbl=$('conn-label');led.className='led '+(ok?S.pending>0?'on-amber':'on-green':'on-red');lbl.textContent=ok?S.pending>0?'busy':label||'connected':label||'offline';} async function loadConfig({silent=false}={}){try{const cfg=await api('/config');S.server.config=cfg;S.server.configOk=true;S.server.lastConfigAt=Date.now();syncIngDraft();syncCfgFromServer();syncFreqPresetIdx(cfg.fm?.frequencyMHz);setConn(true);render();if(!silent)log('Config synchronized','info');return cfg;}catch(e){S.server.configOk=false;if(!S.server.runtimeOk)setConn(false);render();if(!silent)log('Config load failed: '+e.message,'err');throw e;}} async function loadRuntime({silent=true}={}){try{const rt=await api('/runtime');S.server.runtime=rt;S.server.runtimeOk=true;S.server.lastRuntimeAt=Date.now();const synced=syncTransitions(rt.engine);notifyTransition(rt.engine,!synced);pushHistory(rt);setConn(true);render();return rt;}catch(e){S.server.runtimeOk=false;if(!S.server.configOk)setConn(false);render();if(!silent)log('Runtime load failed: '+e.message,'err');throw e;}} +async function loadMeasurements({silent=true}={}){try{const ms=await api('/measurements');S.server.measurements=ms;S.server.lastMeasurementsAt=Date.now();render();return ms;}catch(e){if(!silent)log('Measurements load failed: '+e.message,'err');throw e;}} function syncCfgFromServer(){Object.keys(CFG).forEach(k=>{if(S.cfgDraft[k]===undefined)S.cfgDraft[k]=cfgSrvVal(k);});Object.keys(S.cfgDirty).forEach(s=>{S.cfgDirty[s]=Object.keys(CFG).filter(k=>CFG[k].sec===s).some(k=>S.cfgDraft[k]!==undefined&&!cfgEq(k,S.cfgDraft[k],cfgSrvVal(k)));});} // ── History ──────────────────────────────────────────────────────────────── @@ -996,31 +1025,33 @@ 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||{},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);const queueHealth=String(eng.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||'--';}})(); +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||'--';}})(); 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'}, audio:{state:audio.ToneAmplitude>0&&sourceKind==='none'?'warn':(sourceKind==='none'?'idle':'good'),sub:`Gain ${Number(audio.gain??0).toFixed(2)}`,detail:sourceKind!=='none'?'External program feed':(audio.toneAmplitude>0?'Tone generator armed':'No input source'),lines:[`Gain: ${Number(audio.gain??0).toFixed(2)}`,`Tone L: ${audio.toneLeftHz??'--'} Hz`,`Tone R: ${audio.toneRightHz??'--'} Hz`,`Tone Amp: ${Number(audio.toneAmplitude??0).toFixed(2)}`,`Source kind: ${sourceKind}`],applyMode:'mixed'}, processing:{state:'good',sub:fm.limiterEnabled?'Limiter ON':'Limiter OFF',detail:`Pre-emphasis ${fm.preEmphasisTauUS||0} µs`,lines:[`Limiter: ${fm.limiterEnabled?'on':'off'}`,`Ceiling: ${Number(fm.limiterCeiling??0).toFixed(2)}`,`Pre-emphasis: ${fm.preEmphasisTauUS||0} µs`,`Mode: nominal audio processing path`],applyMode:'mixed'}, - stereo:{state:fm.stereoEnabled?'good':'idle',sub:fm.stereoEnabled?(fm.stereoMode||'DSB'):'MONO',detail:`Pilot ${(Number(fm.pilotLevel??0)*100).toFixed(1)} %`,lines:[`Enabled: ${fm.stereoEnabled?'yes':'no'}`,`Mode: ${fm.stereoMode||'DSB'}`,`Pilot: ${(Number(fm.pilotLevel??0)*100).toFixed(1)}%`],applyMode:'live'}, + stereo:{state:fm.stereoEnabled?'good':'idle',sub:fm.stereoEnabled?((meas.flags?.stereoMode)||fm.stereoMode||'DSB'):'MONO',detail:`Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)} %`,lines:[`Enabled: ${(meas.flags?.stereoEnabled??fm.stereoEnabled)?'yes':'no'}`,`Mode: ${(meas.flags?.stereoMode)||fm.stereoMode||'DSB'}`,`Pilot: ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}%`],applyMode:'live'}, rds:{state:rds.enabled?'good':'idle',sub:rds.enabled?String(eng.activePS||rds.ps||'--'):'DISABLED',detail:rds.enabled?(eng.activeRadioText?String(eng.activeRadioText).slice(0,28):'Radiotext active'):'No subcarrier data',lines:[`Enabled: ${rds.enabled?'yes':'no'}`,`PI: ${rds.pi||'--'}`,`PTY: ${fmtPTY(rds.pty)}`,`PS: ${eng.activePS||rds.ps||'--'}`,`RT: ${eng.activeRadioText||rds.radioText||'--'}`],applyMode:'mixed'}, - mpx:{state:queueHealth==='critical'?'err':(queueHealth==='low'?'warn':'good'),sub:`Pilot ${(Number(fm.pilotLevel??0)*100).toFixed(1)}% / RDS ${(Number(fm.rdsInjection??0)*100).toFixed(1)}%`,detail:queueHealth==='critical'?'Queue critical':(queueHealth==='low'?'Queue low':(fm.bs412Enabled?`BS.412 active · ${Number(fm.bs412ThresholdDBr??0).toFixed(1)} dBr`:(fm.compositeClipper?.enabled?'Composite clipper enabled':`MPX gain ${Number(fm.mpxGain??1).toFixed(2)}`))),lines:[`Pilot: ${(Number(fm.pilotLevel??0)*100).toFixed(1)}%`,`RDS inj: ${(Number(fm.rdsInjection??0)*100).toFixed(1)}%`,`MPX gain: ${Number(fm.mpxGain??1).toFixed(2)}`,`Clipper: ${fm.compositeClipper?.enabled?'on':'off'}`,`BS.412: ${fm.bs412Enabled?'on':'off'}`,`Mode: ${queueHealth==='critical'?'queue health critical':(queueHealth==='low'?'queue health low':(fm.bs412Enabled?'compliance shaping active':(fm.compositeClipper?.enabled?'clipper engaged':'nominal multiplex path')))}`],applyMode:'mixed'}, - tx:{state:txState==='running'?'good':(txState==='faulted'?'err':(txState==='idle'?'idle':'warn')),sub:isFinite(Number(eng.appliedFrequencyMHz))?`${Number(eng.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(fm.frequencyMHz))?`${Number(fm.frequencyMHz).toFixed(1)} MHz`:'--'),detail:String(eng.state||'idle').toUpperCase(),lines:[`State: ${String(eng.state||'idle').toUpperCase()}`,`Applied: ${isFinite(Number(eng.appliedFrequencyMHz))?Number(eng.appliedFrequencyMHz).toFixed(1)+' MHz':'--'}`,`Target: ${isFinite(Number(fm.frequencyMHz))?Number(fm.frequencyMHz).toFixed(1)+' MHz':'--'}`,`Queue: ${eng.queue?.health||'--'}`,`Underruns: ${eng.underruns??'--'}`],applyMode:'live'}, + mpx:{state:queueHealth==='critical'?'err':(queueHealth==='low'?'warn':'good'),sub:`Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}% / Peak ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(2):'--'}`,detail:queueHealth==='critical'?'Queue critical':(queueHealth==='low'?'Queue low':(meas.flags?.bs412Enabled?`BS.412 active · gain ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(2):'--'}`:((meas.flags?.compositeClipperEnabled??fm.compositeClipper?.enabled)?'Composite clipper enabled':`MPX gain ${Number(fm.mpxGain??1).toFixed(2)}`))),lines:[`Pilot: ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}%`,`Pilot RMS: ${typeof meas.compositeFinalPreIq?.pilotRms==='number'?Number(meas.compositeFinalPreIq.pilotRms).toFixed(3):'--'}`,`Composite peak: ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(3):'--'}`,`Clipper LA gain: ${typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3):'--'}`,`BS.412 gain: ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(3):'--'}`,`Flags: ${joinParts([(meas.flags?.watermarkEnabled?'WM':'').trim(),(meas.flags?.rds2Enabled?'RDS2':''),(meas.flags?.licenseInjectionActive?'JINGLE':'')])||'none'}`],applyMode:'mixed'}, + tx:{state:txState==='running'?'good':(txState==='faulted'?'err':(txState==='idle'?'idle':'warn')),sub:isFinite(Number(eng.appliedFrequencyMHz))?`${Number(eng.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(measWrap.appliedFrequencyMHz))?`${Number(measWrap.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(fm.frequencyMHz))?`${Number(fm.frequencyMHz).toFixed(1)} MHz`:'--')),detail:String(eng.state||measWrap.state||'idle').toUpperCase(),lines:[`State: ${String(eng.state||measWrap.state||'idle').toUpperCase()}`,`Applied: ${isFinite(Number(eng.appliedFrequencyMHz))?Number(eng.appliedFrequencyMHz).toFixed(1)+' MHz':(isFinite(Number(measWrap.appliedFrequencyMHz))?Number(measWrap.appliedFrequencyMHz).toFixed(1)+' MHz':'--')}`,`Target: ${isFinite(Number(fm.frequencyMHz))?Number(fm.frequencyMHz).toFixed(1)+' MHz':'--'}`,`Queue: ${eng.queue?.health||measWrap.queue?.health||'--'}`,`Underruns: ${eng.underruns??'--'}`],applyMode:'live'}, }; } -function renderFlow(){const chain=$('flow-chain');if(!chain)return;const data=flowNodeData();chain.innerHTML=FLOW_NODES.map(node=>{const d=data[node.key]||{state:'idle',sub:'--',detail:'--'};const sel=S.flowSelected===node.key?' selected':'';const terminal=node.key==='tx'?' terminal':'';return ``;}).join(''); +function renderFlow(){const chain=$('flow-chain');if(!chain)return;const data=flowNodeData();const meas=S.server.measurements?.measurement||S.server.runtime?.engine?.measurement||{};chain.innerHTML=FLOW_NODES.map(node=>{const d=data[node.key]||{state:'idle',sub:'--',detail:'--'};const sel=S.flowSelected===node.key?' selected':'';const terminal=node.key==='tx'?' terminal':'';return ``;}).join(''); chain.querySelectorAll('[data-flow-node]').forEach(el=>{const key=el.dataset.flowNode;el.addEventListener('mouseenter',e=>showFlowTooltip(key,e.currentTarget));el.addEventListener('mouseleave',hideFlowTooltip);el.addEventListener('focus',e=>showFlowTooltip(key,e.currentTarget));el.addEventListener('blur',hideFlowTooltip);el.addEventListener('click',()=>openFlowPopover(key,el));}); const issue=flowSeverityFromRuntime();const banner=$('flow-banner');if(banner){if(issue){banner.className=`flow-banner show ${issue.sev}`;$('flow-banner-title').textContent=issue.title;$('flow-banner-text').textContent=issue.text;}else{banner.className='flow-banner';$('flow-banner-title').textContent='Status';$('flow-banner-text').textContent='No active issues.';}} - const masterState=String((S.server.runtime?.engine?.state||'idle')).toUpperCase();setText('flow-master-state',masterState);const fms=$('flow-master-state');if(fms){fms.className=`flow-master-state ${stateClass(S.server.runtime?.engine?.state)}`;}setText('flow-master-sub',issue?issue.text:(masterState==='RUNNING'?'Transmission active and signal path healthy.':masterState==='IDLE'?'Transmitter idle. Configure and start when ready.':'Runtime state present; inspect flow modules for details.')); + const masterState=String((S.server.runtime?.engine?.state||'idle')).toUpperCase();setText('flow-master-state',masterState);const fms=$('flow-master-state');if(fms){fms.className=`flow-master-state ${stateClass(S.server.runtime?.engine?.state)}`;}setText('flow-master-sub',issue?issue.text:(masterState==='RUNNING'?(typeof meas.compositeFinalPreIq?.peakAbs==='number'?`Transmission active · composite peak ${Number(meas.compositeFinalPreIq.peakAbs).toFixed(2)} · pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):'--'}%`:'Transmission active and signal path healthy.'):masterState==='IDLE'?'Transmitter idle. Configure and start when ready.':'Runtime state present; inspect flow modules for details.')); const sourceSummary=(()=>{const kind=String(S.server.runtime?.ingest?.active?.kind||S.server.config?.ingest?.kind||'none');const endpoint=String(S.server.runtime?.ingest?.active?.origin?.endpoint||'');try{return joinParts([kind,new URL(endpoint).host]);}catch{return joinParts([kind,S.server.runtime?.ingest?.active?.origin?.streamName||'']);}})(); - setText('flow-top-applied',isFinite(Number(S.server.runtime?.engine?.appliedFrequencyMHz))?`${Number(S.server.runtime.engine.appliedFrequencyMHz).toFixed(1)} MHz`:'--');setText('flow-top-target',isFinite(Number(S.server.config?.fm?.frequencyMHz))?`${Number(S.server.config.fm.frequencyMHz).toFixed(1)} MHz`:'--');setText('flow-top-source',sourceSummary||'--');setText('flow-top-alert',issue?issue.text:'None');setText('flow-bottom-queue',String(S.server.runtime?.engine?.queue?.health||'--').toUpperCase());setText('flow-bottom-ingest',String(S.server.runtime?.ingest?.runtime?.state||S.server.runtime?.ingest?.source?.state||'--').toUpperCase());setText('flow-bottom-age',fmtTime(Number(S.server.runtime?.engine?.runtimeStateDurationSeconds)));setText('flow-bottom-update',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0)));renderFlowPopover(); + const ingestState=(S.server.runtime?.ingest?.runtime?.state||S.server.runtime?.ingest?.source?.state||'--'); + const runtimeAge=Number(S.server.runtime?.engine?.runtimeStateDurationSeconds); + setText('flow-top-applied',isFinite(Number(S.server.runtime?.engine?.appliedFrequencyMHz))?`${Number(S.server.runtime.engine.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(S.server.measurements?.appliedFrequencyMHz))?`${Number(S.server.measurements.appliedFrequencyMHz).toFixed(1)} MHz`:'--'));setText('flow-top-target',isFinite(Number(S.server.config?.fm?.frequencyMHz))?`${Number(S.server.config.fm.frequencyMHz).toFixed(1)} MHz`:'--');setText('flow-top-source',sourceSummary||'--');setText('flow-top-alert',issue?issue.text:(S.server.measurements?.runtimeAlert||'None'));setText('flow-bottom-queue',String(S.server.runtime?.engine?.queue?.health||S.server.measurements?.queue?.health||'--').toUpperCase());setText('flow-bottom-ingest',String(ingestState||'--').toUpperCase());setText('flow-bottom-age',isFinite(runtimeAge)?fmtTime(runtimeAge):'--');setText('flow-bottom-update',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0,S.server.lastMeasurementsAt||0)));renderFlowPopover(); } function showFlowTooltip(key,anchor){const tip=$('flow-tooltip');if(!tip||S.flowSelected===key)return;const d=flowNodeData()[key];if(!d)return;tip.innerHTML=`
${FLOW_NODES.find(n=>n.key===key)?.label||key}
${String(d.state||'idle').toUpperCase()}
${(d.lines||[]).map(line=>`
${line}
`).join('')}
`;const r=anchor.getBoundingClientRect();tip.style.left=`${Math.min(window.innerWidth-300,Math.max(12,r.left + window.scrollX))}px`;tip.style.top=`${r.bottom + window.scrollY + 8}px`;tip.classList.add('show');} function hideFlowTooltip(){const tip=$('flow-tooltip');if(tip)tip.classList.remove('show');} function openFlowPopover(key,anchor){S.flowSelected=S.flowSelected===key?null:key;S.flowAnchor=S.flowSelected?anchor:null;hideFlowTooltip();render();} function closeFlowPopover(){S.flowSelected=null;S.flowAnchor=null;render();} function flowJump(tab){document.querySelectorAll('.tab-btn[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));document.querySelectorAll('.tab-panel[data-tab-panel]').forEach(p=>p.classList.toggle('active',p.dataset.tabPanel===tab));closeFlowPopover();} -function renderFlowPopover(){const pop=$('flow-popover');if(!pop)return;if(!S.flowSelected||!S.flowAnchor){pop.classList.remove('show');return;}const key=S.flowSelected;const d=flowNodeData()[key];if(!d){pop.classList.remove('show');return;}const cfg=S.server.config||{};let fields='';let actions='';let modeNote='';if(key==='tx'){modeNote='Applies immediately.';fields=`
`;actions=``;}else if(key==='rds'){modeNote='Mixed runtime impact: enable/PS/RT apply now; PI/PTY belong in detailed controls.';fields=`
`;actions=``;}else if(key==='source'||key==='ingest'){modeNote='Requires hard reload via detailed ingest configuration.';fields=`
`;actions=``;}else if(key==='processing'){modeNote='Mixed runtime impact: limiter and ceiling apply now; pre-emphasis stays in detailed controls.';fields=`
`;actions=``;}else if(key==='mpx'){modeNote='Mixed runtime impact: pilot/RDS injection and clipper enable apply now; BS.412, MPX gain and structural clipper settings stay in detailed controls.';fields=`
`;actions=``;}else if(key==='audio'){modeNote='Mixed runtime impact: tone controls apply now; input gain remains in detailed controls.';fields=`
`;actions=``;}else if(key==='stereo'){modeNote='Applies immediately.';fields=`
`;actions=``;} +function renderFlowPopover(){const pop=$('flow-popover');if(!pop)return;if(!S.flowSelected||!S.flowAnchor){pop.classList.remove('show');return;}const key=S.flowSelected;const d=flowNodeData()[key];if(!d){pop.classList.remove('show');return;}const cfg=S.server.config||{};let fields='';let actions='';let modeNote='';if(key==='tx'){modeNote='Applies immediately.';fields=`
`;actions=``;}else if(key==='rds'){modeNote='Mixed runtime impact: enable/PS/RT apply now; PI/PTY belong in detailed controls.';fields=`
`;actions=``;}else if(key==='source'||key==='ingest'){modeNote='Requires hard reload via detailed ingest configuration.';fields=`
`;actions=``;}else if(key==='processing'){modeNote='Mixed runtime impact: limiter and ceiling apply now; pre-emphasis stays in detailed controls.';fields=`
`;actions=``;}else if(key==='mpx'){const meas=S.server.measurements?.measurement||S.server.runtime?.engine?.measurement||{};const flags=meas.flags||{};modeNote='Mixed runtime impact: pilot/RDS injection and clipper enable apply now; BS.412, MPX gain and structural clipper settings stay in detailed controls.';fields=`
Measured path: pre-BS.412 RMS ${typeof meas.audioMpxPreBs412?.rms==='number'?Number(meas.audioMpxPreBs412.rms).toFixed(3):'--'} · post-BS.412 RMS ${typeof meas.audioMpxPostBs412?.rms==='number'?Number(meas.audioMpxPostBs412.rms).toFixed(3):'--'} · final peak ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(3):'--'}
Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):'--'}% · Pilot RMS ${typeof meas.compositeFinalPreIq?.pilotRms==='number'?Number(meas.compositeFinalPreIq.pilotRms).toFixed(3):'--'} · RDS peak ${typeof meas.compositeFinalPreIq?.rdsPeakAbs==='number'?Number(meas.compositeFinalPreIq.rdsPeakAbs).toFixed(3):'--'}
Clipper LA ${typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3):'--'} · Env ${typeof meas.audioMpxPreBs412?.clipperEnvelope==='number'?Number(meas.audioMpxPreBs412.clipperEnvelope).toFixed(3):'--'} · BS.412 gain ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(3):'--'}
Flags: ${joinParts([flags.stereoEnabled?`stereo ${flags.stereoMode||'DSB'}`:'mono',flags.watermarkEnabled?'watermark':'',flags.rdsEnabled?'rds':'',flags.rds2Enabled?'rds2':'',flags.licenseInjectionActive?'jingle':''])||'none'}
`;actions=``;}else if(key==='audio'){modeNote='Mixed runtime impact: tone controls apply now; input gain remains in detailed controls.';fields=`
`;actions=``;}else if(key==='stereo'){modeNote='Applies immediately.';fields=`
`;actions=``;} pop.innerHTML=`
${FLOW_NODES.find(n=>n.key===key)?.label||key}
${String(d.state||'idle').toUpperCase()} · ${d.sub||''}
${(d.lines||[]).map(line=>`
${line}
`).join('')}
${modeNote}
${fields}
${actions}
`; const r=S.flowAnchor.getBoundingClientRect();pop.style.left=`${Math.min(window.innerWidth-380,Math.max(12,r.left + window.scrollX))}px`;pop.style.top=`${r.bottom + window.scrollY + 10}px`;pop.classList.add('show');$('flow-popover-close')?.addEventListener('click',closeFlowPopover); $('flow-action-start')?.addEventListener('click',()=>{closeFlowPopover();txAction('start');});$('flow-action-stop')?.addEventListener('click',()=>{closeFlowPopover();txAction('stop');});$('flow-action-reset-fault')?.addEventListener('click',()=>{closeFlowPopover();resetFault();});$('flow-open-control')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-rds')?.addEventListener('click',()=>flowJump('rds'));$('flow-open-ingest')?.addEventListener('click',()=>flowJump('ingest'));$('flow-open-diagnostics')?.addEventListener('click',()=>flowJump('diagnostics'));$('flow-open-processing')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-audio')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-stereo')?.addEventListener('click',()=>flowJump('tx')); @@ -1051,6 +1082,8 @@ function setHTML(id,h){const el=$(id);if(el&&el.innerHTML!==h)el.innerHTML=h;} 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=``;} +function updateHoldMeter(state,targetRms,targetPeak,{attack=1,decay=0.18,holdDecay=0.04}={}){state.rms=targetRms>state.rms?targetRms:Math.max(targetRms,state.rms-decay);state.peak=targetPeak>state.peak?targetPeak:Math.max(targetPeak,state.peak-decay*1.2);state.hold=targetPeak>=state.hold?targetPeak:Math.max(state.peak,state.hold-holdDecay);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 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'));} @@ -1115,7 +1148,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; + 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||{}; // ── Header setText('badge-backend',cfg.backend?.kind||cfg.backend||'--'); @@ -1123,7 +1156,7 @@ function _render(){ setText('badge-live',S.server.runtimeOk?'Connected':'Waiting'); // ── Overview: freq display - const applied=Number(eng.appliedFrequencyMHz),desired=Number(cfg.fm?.frequencyMHz); + const applied=isFinite(Number(eng.appliedFrequencyMHz))?Number(eng.appliedFrequencyMHz):(isFinite(Number(S.server.measurements?.appliedFrequencyMHz))?Number(S.server.measurements.appliedFrequencyMHz):NaN),desired=Number(cfg.fm?.frequencyMHz); const dispFreq=isFinite(applied)?applied:effVal('frequencyMHz')??desired; setHTML('freq-display',`${typeof dispFreq==='number'?dispFreq.toFixed(1):'---.-'}MHz`); setText('freq-applied',isFinite(applied)?`Applied ${applied.toFixed(1)} MHz`:'Applied --'); @@ -1152,6 +1185,14 @@ function _render(){ 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; setMeter('meter-tx-fill','meter-tx-text',txR,txSt==='running'?'Live':S.txBusy?'Working':'Idle',txSt==='running'?'good':S.txBusy?'warn':'err'); + const audioL=updateHoldMeter(S.meterState.audioL,Number(meas.lrPreEncodePostWatermark?.lRms||0),Number(meas.lrPreEncodePostWatermark?.lPeakAbs||0)); + const audioR=updateHoldMeter(S.meterState.audioR,Number(meas.lrPreEncodePostWatermark?.rRms||0),Number(meas.lrPreEncodePostWatermark?.rPeakAbs||0)); + const mpx=updateHoldMeter(S.meterState.mpx,0,Number(meas.compositeFinalPreIq?.peakAbs||0),{decay:0.12,holdDecay:0.025}); + renderHifiMeter('audio-l-rms-fill',null,'audio-l-rms-text',audioL.rms,0,typeof meas.lrPreEncodePostWatermark?.lRms==='number'?Number(meas.lrPreEncodePostWatermark.lRms).toFixed(2):'--'); + renderHifiMeter('audio-r-rms-fill',null,'audio-r-rms-text',audioR.rms,0,typeof meas.lrPreEncodePostWatermark?.rRms==='number'?Number(meas.lrPreEncodePostWatermark.rRms).toFixed(2):'--'); + renderHifiMeter('audio-l-peak-fill','audio-l-peak-marker','audio-l-peak-text',audioL.peak,audioL.hold,typeof meas.lrPreEncodePostWatermark?.lPeakAbs==='number'?Number(meas.lrPreEncodePostWatermark.lPeakAbs).toFixed(2):'--'); + renderHifiMeter('audio-r-peak-fill','audio-r-peak-marker','audio-r-peak-text',audioR.peak,audioR.hold,typeof meas.lrPreEncodePostWatermark?.rPeakAbs==='number'?Number(meas.lrPreEncodePostWatermark.rPeakAbs).toFixed(2):'--'); + renderHifiMeter('mpx-peak-fill','mpx-peak-marker','mpx-peak-text',mpx.peak,mpx.hold,typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(2):'--',1.1); 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); @@ -1163,10 +1204,10 @@ function _render(){ setText('info-runtime-age',ageStr(S.server.lastRuntimeAt));setText('info-last-alert',eng.runtimeAlert||eng.lastError||'None'); setText('info-drive',cfg.fm?.outputDrive!=null?Number(cfg.fm.outputDrive).toFixed(2):'--'); setText('info-limiter',cfg.fm?.limiterEnabled?(cfg.fm?.limiterCeiling!=null?`Limiter ON · clips always active · ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)}`:'Limiter ON · clips always active'):'Limiter OFF · hard clips still active'); - setText('info-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',fmtPilot(cfg.fm?.rdsInjection)); + setText('info-pilot',typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?`${Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1)}% (measured)`:fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',typeof meas.compositeFinalPreIq?.rdsPeakAbs==='number'?`${Number(meas.compositeFinalPreIq.rdsPeakAbs).toFixed(3)} pk`:fmtPilot(cfg.fm?.rdsInjection)); setText('info-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--'); - setText('info-bs412',cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF'); - const cc=cfg.fm?.compositeClipper;setText('info-compclip',cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF'); + setText('info-bs412',typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?`ON · gain ${Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(2)}`:(cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF')); + const cc=cfg.fm?.compositeClipper;setText('info-compclip',typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?`LA ${Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3)} · Env ${Number(meas.audioMpxPreBs412.clipperEnvelope??0).toFixed(3)}`:(cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF')); // ── TX Control tab // Freq @@ -1390,15 +1431,15 @@ function bindAll(){ const respHandler=()=>{if(!mobileMq.matches)S.mobilePanelsApplied=false;else applyMobilePanels();};if(mobileMq.addEventListener)mobileMq.addEventListener('change',respHandler);else mobileMq.addListener(respHandler); } -async function manualRefresh(){beginReq();try{await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);toast('UI data refreshed','info');log('Manual refresh','info');}finally{endReq();}} -function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);} +async function manualRefresh(){beginReq();try{await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true}),loadMeasurements({silent:true})]);toast('UI data refreshed','info');log('Manual refresh','info');}finally{endReq();}} +function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);setInterval(()=>loadMeasurements({silent:true}),200);} async function init(){ bindAll();render(); log('ferrite.fm control UI booting','info'); - await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true})]); + await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true}),loadMeasurements({silent:true})]); render();startPollers(); - log('Polling active: runtime 1s · config 8s','ok'); + log('Polling active: runtime 1s · config 8s · measurements 200ms','ok'); log('UI ready','info'); } init(); diff --git a/internal/license/license.go b/internal/license/license.go index 5beca35..82378c6 100644 --- a/internal/license/license.go +++ b/internal/license/license.go @@ -59,6 +59,9 @@ func NewState(key string) *State { // Licensed reports whether a valid key was supplied. func (s *State) Licensed() bool { return s.licensed } +// Active reports whether the jingle is currently playing. +func (s *State) Active() bool { return s.active } + // NextSample returns the jingle contribution for one composite sample. // Call once per sample from the DSP loop — it is not thread-safe and must // be called from the single GenerateFrame goroutine only. diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 02d5f81..aee526b 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "log" + "math" "path/filepath" "sync/atomic" "time" @@ -81,6 +82,73 @@ type SourceInfo struct { Detail string } +type MeasurementFlags struct { + StereoEnabled bool `json:"stereoEnabled"` + StereoMode string `json:"stereoMode"` + RDSEnabled bool `json:"rdsEnabled"` + RDS2Enabled bool `json:"rds2Enabled"` + BS412Enabled bool `json:"bs412Enabled"` + CompositeClipperEnabled bool `json:"compositeClipperEnabled"` + WatermarkEnabled bool `json:"watermarkEnabled"` + LicenseInjectionActive bool `json:"licenseInjectionActive"` +} + +type LRPreEncodePostWatermarkMeasurement struct { + LRms float64 `json:"lRms"` + RRms float64 `json:"rRms"` + LPeakAbs float64 `json:"lPeakAbs"` + RPeakAbs float64 `json:"rPeakAbs"` + LRBalanceDB float64 `json:"lrBalanceDb"` + LClipEvents uint32 `json:"lClipEvents"` + RClipEvents uint32 `json:"rClipEvents"` +} + +type AudioMPXPreBS412Measurement struct { + RMS float64 `json:"rms"` + PeakAbs float64 `json:"peakAbs"` + MonoRMS float64 `json:"monoRms"` + StereoRMS float64 `json:"stereoRms"` + CrestFactor float64 `json:"crestFactor"` + ClipperLookaheadGain float64 `json:"clipperLookaheadGain"` + ClipperEnvelope float64 `json:"clipperEnvelope"` + ClipperOrProtectionActive bool `json:"clipperOrProtectionActive"` +} + +type AudioMPXPostBS412Measurement struct { + RMS float64 `json:"rms"` + PeakAbs float64 `json:"peakAbs"` + BS412GainApplied float64 `json:"bs412GainApplied"` + BS412AttenuationDB float64 `json:"bs412AttenuationDb"` + EstimatedAudioPower float64 `json:"estimatedAudioPower"` +} + +type CompositeFinalPreIQMeasurement struct { + RMS float64 `json:"rms"` + PeakAbs float64 `json:"peakAbs"` + PilotRMS float64 `json:"pilotRms"` + PilotPeakAbs float64 `json:"pilotPeakAbs"` + PilotInjectionEquivalentPercent float64 `json:"pilotInjectionEquivalentPercent"` + RDSRMS float64 `json:"rdsRms"` + RDSPeakAbs float64 `json:"rdsPeakAbs"` + OverNominalEvents uint32 `json:"overNominalEvents"` + OverHeadroomEvents uint32 `json:"overHeadroomEvents"` +} + +type MeasurementSnapshot struct { + Timestamp time.Time `json:"timestamp"` + SampleRateHz float64 `json:"sampleRateHz"` + ChunkSamples int `json:"chunkSamples"` + ChunkDurationMs float64 `json:"chunkDurationMs"` + Sequence uint64 `json:"sequence"` + Stale bool `json:"stale"` + NoData bool `json:"noData"` + Flags MeasurementFlags `json:"flags"` + LRPreEncodePostWatermark LRPreEncodePostWatermarkMeasurement `json:"lrPreEncodePostWatermark"` + AudioMPXPreBS412 AudioMPXPreBS412Measurement `json:"audioMpxPreBs412"` + AudioMPXPostBS412 AudioMPXPostBS412Measurement `json:"audioMpxPostBs412"` + CompositeFinalPreIQ CompositeFinalPreIQMeasurement `json:"compositeFinalPreIq"` +} + type Generator struct { cfg cfgpkg.Config @@ -141,6 +209,8 @@ type Generator struct { stftEmbedder *watermark.STFTEmbedder wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample + + latestMeasurement atomic.Pointer[MeasurementSnapshot] } func NewGenerator(cfg cfgpkg.Config) *Generator { @@ -205,6 +275,14 @@ func (g *Generator) RDSEncoder() *rds.Encoder { return g.rdsEnc } +func (g *Generator) LatestMeasurement() *MeasurementSnapshot { + if m := g.latestMeasurement.Load(); m != nil { + copy := *m + return © + } + return nil +} + func (g *Generator) resetSource() { rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) @@ -469,6 +547,30 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} } +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func safeRMS(sumSquares float64, n int) float64 { + if n <= 0 { + return 0 + } + return math.Sqrt(sumSquares / float64(n)) +} + +func safeDBRatio(a, b float64) float64 { + if a <= 0 || b <= 0 { + return 0 + } + return 20 * math.Log10(a/b) +} + func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { g.init() @@ -495,6 +597,20 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lBuf := make([]float64, samples) rBuf := make([]float64, samples) + var lrLSumSq, lrRSumSq float64 + var lrLPeak, lrRPeak float64 + var lrLClip, lrRClip uint32 + var preMonoSumSq, preStereoSumSq, preAudioMpxSumSq float64 + var preAudioMpxPeak float64 + var postAudioMpxSumSq float64 + var postAudioMpxPeak float64 + var finalCompositeSumSq float64 + var finalCompositePeak float64 + var pilotSumSq, rdsSumSq float64 + var pilotPeak, rdsPeak float64 + var overNominal, overHeadroom uint32 + licenseInjectionActive := false + // Load live params once per chunk — single atomic read, zero per-sample cost lp := g.liveParams.Load() if lp == nil { @@ -647,16 +763,32 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame for i := 0; i < samples; i++ { l := lBuf[i] r := rBuf[i] + lrLSumSq += l * l + lrRSumSq += r * r + if abs := math.Abs(l); abs > lrLPeak { + lrLPeak = abs + } + if abs := math.Abs(r); abs > lrRPeak { + lrRPeak = abs + } + if math.Abs(l) >= ceiling { + lrLClip++ + } + if math.Abs(r) >= ceiling { + lrRClip++ + } // --- Stage 4: Stereo encode --- limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) comps := g.stereoEncoder.Encode(limited) // --- Stage 5: Composite clip + protection --- - audioMPX := float64(comps.Mono) + monoComponent := float64(comps.Mono) + stereoComponent := 0.0 if lp.StereoEnabled { - audioMPX += float64(comps.Stereo) + stereoComponent = float64(comps.Stereo) } + audioMPX := monoComponent + stereoComponent if lp.CompositeClipperEnabled && g.compositeClip != nil { // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip audioMPX = g.compositeClip.Process(audioMPX) @@ -667,10 +799,21 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame audioMPX = g.mpxNotch57.Process(audioMPX) } + preAudioMpxSumSq += audioMPX * audioMPX + preMonoSumSq += monoComponent * monoComponent + preStereoSumSq += stereoComponent * stereoComponent + if abs := math.Abs(audioMPX); abs > preAudioMpxPeak { + preAudioMpxPeak = abs + } + // BS.412: apply gain and measure power if bs412Gain < 1.0 { audioMPX *= bs412Gain } + postAudioMpxSumSq += audioMPX * audioMPX + if abs := math.Abs(audioMPX); abs > postAudioMpxPeak { + postAudioMpxPeak = abs + } bs412PowerAccum += audioMPX * audioMPX // --- Stage 6: Add protected components --- @@ -678,10 +821,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame if lp.StereoEnabled { composite += pilotAmp * comps.Pilot } + rdsContribution := 0.0 if g.rdsEnc != nil && lp.RDSEnabled { rdsCarrier := g.stereoEncoder.RDSCarrier() rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier) - composite += rdsAmp * rdsValue + rdsContribution = rdsAmp * rdsValue + composite += rdsContribution } // RDS2: three additional subcarriers (66.5, 71.25, 76 kHz) @@ -695,7 +840,33 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame // Jingle: injected when unlicensed, bypasses drive/gain controls. if g.licenseState != nil && len(g.jingleFrames) > 0 { - composite += g.licenseState.NextSample(g.jingleFrames) + jingleContribution := g.licenseState.NextSample(g.jingleFrames) + if jingleContribution != 0 { + licenseInjectionActive = true + } + composite += jingleContribution + } + pilotContribution := 0.0 + if lp.StereoEnabled { + pilotContribution = pilotAmp * comps.Pilot + } + pilotSumSq += pilotContribution * pilotContribution + if abs := math.Abs(pilotContribution); abs > pilotPeak { + pilotPeak = abs + } + rdsSumSq += rdsContribution * rdsContribution + if abs := math.Abs(rdsContribution); abs > rdsPeak { + rdsPeak = abs + } + finalCompositeSumSq += composite * composite + if abs := math.Abs(composite); abs > finalCompositePeak { + finalCompositePeak = abs + } + if math.Abs(composite) > 1.0 { + overNominal++ + } + if math.Abs(composite) > 1.1 { + overHeadroom++ } if g.fmMod != nil { iq_i, iq_q := g.fmMod.Modulate(composite) @@ -705,6 +876,76 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame } } + preAudioRMS := safeRMS(preAudioMpxSumSq, samples) + postAudioRMS := safeRMS(postAudioMpxSumSq, samples) + lRMS := safeRMS(lrLSumSq, samples) + rRMS := safeRMS(lrRSumSq, samples) + monoRMS := safeRMS(preMonoSumSq, samples) + stereoRMS := safeRMS(preStereoSumSq, samples) + finalCompositeRMS := safeRMS(finalCompositeSumSq, samples) + pilotRMS := safeRMS(pilotSumSq, samples) + rdsRMS := safeRMS(rdsSumSq, samples) + lrBalanceDB := safeDBRatio(lRMS, rRMS) + clipperStats := dsp.CompositeClipperStats{} + if g.compositeClip != nil { + clipperStats = g.compositeClip.Stats() + } + measurement := &MeasurementSnapshot{ + Timestamp: frame.Timestamp, + SampleRateHz: g.sampleRate, + ChunkSamples: samples, + ChunkDurationMs: float64(samples) / g.sampleRate * 1000, + Sequence: frame.Sequence, + Flags: MeasurementFlags{ + StereoEnabled: lp.StereoEnabled, + StereoMode: g.appliedStereoMode, + RDSEnabled: lp.RDSEnabled, + RDS2Enabled: g.rds2Enc != nil && g.rds2Enc.Enabled(), + BS412Enabled: g.bs412 != nil, + CompositeClipperEnabled: lp.CompositeClipperEnabled, + WatermarkEnabled: g.stftEmbedder != nil, + LicenseInjectionActive: licenseInjectionActive, + }, + LRPreEncodePostWatermark: LRPreEncodePostWatermarkMeasurement{ + LRms: lRMS, + RRms: rRMS, + LPeakAbs: lrLPeak, + RPeakAbs: lrRPeak, + LRBalanceDB: lrBalanceDB, + LClipEvents: lrLClip, + RClipEvents: lrRClip, + }, + AudioMPXPreBS412: AudioMPXPreBS412Measurement{ + RMS: preAudioRMS, + PeakAbs: preAudioMpxPeak, + MonoRMS: monoRMS, + StereoRMS: stereoRMS, + CrestFactor: func() float64 { if preAudioRMS > 0 { return preAudioMpxPeak / preAudioRMS }; return 0 }(), + ClipperLookaheadGain: clipperStats.LookaheadGain, + ClipperEnvelope: clipperStats.Envelope, + ClipperOrProtectionActive: clipperStats.LookaheadGain < 0.999 || clipperStats.Envelope > 1.0, + }, + AudioMPXPostBS412: AudioMPXPostBS412Measurement{ + RMS: postAudioRMS, + PeakAbs: postAudioMpxPeak, + BS412GainApplied: bs412Gain, + BS412AttenuationDB: func() float64 { if bs412Gain > 0 { return 20 * math.Log10(bs412Gain) }; return 0 }(), + EstimatedAudioPower: func() float64 { if samples > 0 { return bs412PowerAccum / float64(samples) }; return 0 }(), + }, + CompositeFinalPreIQ: CompositeFinalPreIQMeasurement{ + RMS: finalCompositeRMS, + PeakAbs: finalCompositePeak, + PilotRMS: pilotRMS, + PilotPeakAbs: pilotPeak, + PilotInjectionEquivalentPercent: clamp01(pilotPeak) * 100, + RDSRMS: rdsRMS, + RDSPeakAbs: rdsPeak, + OverNominalEvents: overNominal, + OverHeadroomEvents: overHeadroom, + }, + } + g.latestMeasurement.Store(measurement) + // BS.412: feed this chunk's actual duration and average audio power for // the next chunk's gain calculation. Using the real sample count avoids // the error that occurred when chunkSec was hardcoded to 0.05 — any diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 55d76e3..d3f0de4 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -18,6 +18,16 @@ func TestGenerateFrame(t *testing.T) { if frame == nil || len(frame.Samples) == 0 { t.Fatal("expected samples") } + m := g.LatestMeasurement() + if m == nil { + t.Fatal("expected latest measurement") + } + if m.ChunkSamples == 0 || m.SampleRateHz <= 0 { + t.Fatal("expected measurement metadata") + } + if m.Flags.StereoMode == "" { + t.Fatal("expected stereo mode flag") + } } func TestGenerateFrameFMIQ(t *testing.T) { @@ -210,6 +220,11 @@ func TestConfigureWatermarkExplicitOptIn(t *testing.T) { if g.stftEmbedder == nil { t.Fatal("expected watermark embedder after explicit opt-in") } + _ = g.GenerateFrame(10 * time.Millisecond) + m := g.LatestMeasurement() + if m == nil || !m.Flags.WatermarkEnabled { + t.Fatal("expected watermark flag in measurement") + } } func TestGeneratorResetRestoresDeterministicFirstFrame(t *testing.T) {