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
@@ -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()}
`;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) {