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.
+
+
@@ -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;i
0?'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);