From 8a0c6951727a9e80b296839077554c60ba8ac6ee Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 11 Apr 2026 08:36:35 +0200 Subject: [PATCH] feat: add composite clipper live controls --- cmd/fmrtx/main.go | 4 +++- docs/config.plutosdr.json | 2 +- internal/app/engine.go | 4 ++++ internal/control/control.go | 21 ++++++++++++++++- internal/control/ui.html | 45 ++++++++++++++++++++++++++++++++++--- 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 810894c..35ef2aa 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -13,6 +13,7 @@ import ( apppkg "github.com/jan/fm-rds-tx/internal/app" "github.com/jan/fm-rds-tx/internal/license" + "github.com/jan/fm-rds-tx/internal/watermark" "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" @@ -185,7 +186,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver log.Printf("license: no valid key — evaluation jingle every %d minutes", license.JingleIntervalMinutes) } engine.SetLicenseState(licState, licenseKey) - log.Printf("watermark: embedding key fingerprint into composite signal") + log.Printf("watermark: embedding key fingerprint — Level=%.3f ChipRate=%d", watermark.Level, watermark.ChipRate) cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) var streamSrc *audio.StreamSource @@ -376,6 +377,7 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { ToneRightHz: lp.ToneRightHz, ToneAmplitude: lp.ToneAmplitude, AudioGain: lp.AudioGain, + CompositeClipperEnabled: lp.CompositeClipperEnabled, }) } diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 2a9939c..3241830 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -36,7 +36,7 @@ "deviceSampleRateHz": 2280000 }, "control": { - "listenAddress": "127.0.0.1:8088" + "listenAddress": "0.0.0.0:8088" }, "runtime": { "frameQueueCapacity": 3 diff --git a/internal/app/engine.go b/internal/app/engine.go index 7722dc8..e9d2820 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -294,6 +294,7 @@ type LiveConfigUpdate struct { ToneRightHz *float64 ToneAmplitude *float64 AudioGain *float64 + CompositeClipperEnabled *bool } // UpdateConfig applies live parameter changes without restarting the engine. @@ -396,6 +397,9 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { if u.AudioGain != nil { next.AudioGain = *u.AudioGain } + if u.CompositeClipperEnabled != nil { + next.CompositeClipperEnabled = *u.CompositeClipperEnabled + } e.generator.UpdateLive(next) return nil diff --git a/internal/control/control.go b/internal/control/control.go index 81b012e..4b84a97 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -49,6 +49,7 @@ type LivePatch struct { ToneRightHz *float64 ToneAmplitude *float64 AudioGain *float64 + CompositeClipperEnabled *bool } type Server struct { @@ -139,6 +140,10 @@ type ConfigPatch struct { BS412Enabled *bool `json:"bs412Enabled,omitempty"` BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` MpxGain *float64 `json:"mpxGain,omitempty"` + CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"` + CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"` + CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"` + CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"` } type IngestSaveRequest struct { @@ -592,6 +597,18 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if patch.MpxGain != nil { next.FM.MpxGain = *patch.MpxGain } + if patch.CompositeClipperEnabled != nil { + next.FM.CompositeClipper.Enabled = *patch.CompositeClipperEnabled + } + if patch.CompositeClipperIterations != nil { + next.FM.CompositeClipper.Iterations = *patch.CompositeClipperIterations + } + if patch.CompositeClipperSoftKnee != nil { + next.FM.CompositeClipper.SoftKnee = *patch.CompositeClipperSoftKnee + } + if patch.CompositeClipperLookaheadMs != nil { + next.FM.CompositeClipper.LookaheadMs = *patch.CompositeClipperLookaheadMs + } if err := next.Validate(); err != nil { s.mu.Unlock() http.Error(w, err.Error(), http.StatusBadRequest) @@ -612,6 +629,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { ToneRightHz: patch.ToneRightHz, ToneAmplitude: patch.ToneAmplitude, AudioGain: patch.AudioGain, + CompositeClipperEnabled: patch.CompositeClipperEnabled, } // NEU-02 fix: determine whether any live-patchable fields are present, // then release the lock before calling UpdateConfig to avoid holding @@ -623,7 +641,8 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { patch.LimiterEnabled != nil || patch.LimiterCeiling != nil || patch.PS != nil || patch.RadioText != nil || patch.ToneLeftHz != nil || patch.ToneRightHz != nil || - patch.ToneAmplitude != nil || patch.AudioGain != nil + patch.ToneAmplitude != nil || patch.AudioGain != nil || + patch.CompositeClipperEnabled != nil s.cfg = next s.mu.Unlock() // Apply live fields to running engine outside the lock. diff --git a/internal/control/ui.html b/internal/control/ui.html index aa64eaa..e9db2b7 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -312,6 +312,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
RDS Inj.
--
MPX Gain
--
BS.412
--
+
Comp. Clip
--
@@ -425,6 +426,31 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
Compliance changes are persisted to config and require TX restart before they affect the modulation chain.
+ +
+

Composite Clipper

SM.1268
+
+
ITU-R SM.1268 iterative composite clipper. Enable/disable is live. Iterations, knee, and look-ahead require TX restart.
+
+
Composite Clipper
Iterative clip-filter-clip + look-ahead  live
+
--
+
+
+
Iterationsclip-filter passes (1-5)
+
--restart
+
+
+
Soft Knee0 = hard clip, 0.3 = gentle
+
--restart
+
+
+
Look-ahead0 = off, 1.0 ms = typical
+
--msrestart
+
+
+
Structural clipper changes (iterations, knee, look-ahead) are persisted to config and require TX restart.
+
+

Danger Zone

emergency
@@ -725,8 +751,12 @@ const CFG={ toneLeftHz: {sec:'tones', live:true, path:'audio.toneLeftHz', min:0, max:20000,step:10}, toneRightHz: {sec:'tones', live:true, path:'audio.toneRightHz', min:0, max:20000,step:10}, toneAmplitude: {sec:'tones', live:true, path:'audio.toneAmplitude', min:0, max:1, step:.01}, + compositeClipperEnabled: {sec:'compclip',live:true, path:'fm.compositeClipper.enabled'}, + compositeClipperIterations: {sec:'compclip',live:false,path:'fm.compositeClipper.iterations',min:1,max:5,step:1}, + compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01}, + compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1}, }; -const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude'}; +const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude',compositeClipperEnabled:'compositeClipperEnabled',compositeClipperIterations:'compositeClipperIterations',compositeClipperSoftKnee:'compositeClipperSoftKnee',compositeClipperLookaheadMs:'compositeClipperLookaheadMs'}; // ── Helpers ──────────────────────────────────────────────────────────────── function nearEq(a,b,e=1e-9){if(a==null&&b==null)return true;if(a==null||b==null)return false;return Math.abs(Number(a)-Number(b))<=e;} @@ -734,12 +764,12 @@ function clone(o){return JSON.parse(JSON.stringify(o??{}));} function gp(o,path){const ps=String(path||'').split('.');let c=o;for(const p of ps){if(!p)continue;if(c==null||typeof c!=='object')return undefined;c=c[p];}return c;} function sp(o,path,v){const ps=String(path||'').split('.');let c=o;for(let i=0;i0?'soft':'hard'})`:'OFF'); // ── TX Control tab // Freq @@ -931,6 +962,13 @@ function _render(){ syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2)); setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required'); $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance']; + // Composite Clipper + syncToggle('tog-compclip','compclip-label','compositeClipperEnabled'); + syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v)))); + syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2)); + syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1)); + setText('compclip-meta',S.cfgDirty['compclip']?'Draft pending':'SM.1268'); + $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip']; // Danger $('danger-stop').disabled=S.txBusy;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} const rh=$('reset-hint');if(rh){const sn=normState(eng.state);rh.textContent=sn==='faulted'?'Faulted: reset moves runtime back to DEGRADED.':sn==='muted'||sn==='degraded'?'Reset Fault holds at DEGRADED until queue recovers.':'Manual fault reset drops to DEGRADED while queue recovers.';} @@ -1048,6 +1086,7 @@ function bindAll(){ // Compliance bindCfgSlider('bs412-threshold-slider','bs412ThresholdDBr');bindCfgSlider('mpxgain-slider','mpxGain'); $('compliance-apply').addEventListener('click',()=>applyCfgSection('compliance'));$('compliance-reset').addEventListener('click',()=>{cfgClear('compliance');toast('Draft reset','info');}); + $('compclip-apply').addEventListener('click',()=>applyCfgSection('compclip'));$('compclip-reset').addEventListener('click',()=>{cfgClear('compclip');toast('Draft reset','info');}); // TX $('btn-start').addEventListener('click',()=>txAction('start'));$('btn-stop').addEventListener('click',()=>txAction('stop')); $('danger-stop').addEventListener('click',()=>txAction('stop'));$('btn-refresh').addEventListener('click',manualRefresh);