Bladeren bron

feat: add composite clipper live controls

main
Jan 1 maand geleden
bovenliggende
commit
8a0c695172
5 gewijzigde bestanden met toevoegingen van 70 en 6 verwijderingen
  1. +3
    -1
      cmd/fmrtx/main.go
  2. +1
    -1
      docs/config.plutosdr.json
  3. +4
    -0
      internal/app/engine.go
  4. +20
    -1
      internal/control/control.go
  5. +42
    -3
      internal/control/ui.html

+ 3
- 1
cmd/fmrtx/main.go Bestand weergeven

@@ -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,
})
}



+ 1
- 1
docs/config.plutosdr.json Bestand weergeven

@@ -36,7 +36,7 @@
"deviceSampleRateHz": 2280000
},
"control": {
"listenAddress": "127.0.0.1:8088"
"listenAddress": "0.0.0.0:8088"
},
"runtime": {
"frameQueueCapacity": 3


+ 4
- 0
internal/app/engine.go Bestand weergeven

@@ -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


+ 20
- 1
internal/control/control.go Bestand weergeven

@@ -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.


+ 42
- 3
internal/control/ui.html Bestand weergeven

@@ -312,6 +312,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
<div class="k">RDS Inj.</div><div class="v" id="info-rdsinj">--</div>
<div class="k">MPX Gain</div><div class="v" id="info-mpxgain">--</div>
<div class="k">BS.412</div><div class="v" id="info-bs412">--</div>
<div class="k">Comp. Clip</div><div class="v" id="info-compclip">--</div>
</div>
</div>
</div>
@@ -425,6 +426,31 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
<div class="section-note reset-hint">Compliance changes are persisted to config and require TX restart before they affect the modulation chain.</div>
</div>
</div>
<!-- Composite Clipper -->
<div class="card panel" data-panel-key="compclip">
<div class="panel-head" data-panel><h2>Composite Clipper</h2><div class="meta" id="compclip-meta">SM.1268</div><span class="chevron">▼</span></div>
<div class="panel-body">
<div class="section-note">ITU-R SM.1268 iterative composite clipper. Enable/disable is live. Iterations, knee, and look-ahead require TX restart.</div>
<div class="toggle-row">
<div class="toggle-copy"><div class="title">Composite Clipper</div><div class="sub">Iterative clip-filter-clip + look-ahead &nbsp;<span class="tag tag-live">live</span></div></div>
<div class="toggle-ctl"><div class="toggle" id="tog-compclip" data-toggle="compositeClipperEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="compclip-label">--</div></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Iterations</span><span class="ctrl-sub">clip-filter passes (1-5)</span></div>
<div class="ctrl-input"><input type="range" min="1" max="5" step="1" id="compclip-iter-slider"><span class="val-display" id="compclip-iter-val">--</span><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Soft Knee</span><span class="ctrl-sub">0 = hard clip, 0.3 = gentle</span></div>
<div class="ctrl-input"><input type="range" min="0" max="0.5" step="0.01" id="compclip-knee-slider"><span class="val-display" id="compclip-knee-val">--</span><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Look-ahead</span><span class="ctrl-sub">0 = off, 1.0 ms = typical</span></div>
<div class="ctrl-input"><input type="range" min="0" max="3" step="0.1" id="compclip-la-slider"><span class="val-display" id="compclip-la-val">--</span><span class="unit-label">ms</span><span class="tag tag-restart">restart</span></div>
</div>
<div class="actions-row"><button class="apply-btn" id="compclip-apply" type="button">Save Clipper Settings</button><button class="apply-btn secondary" id="compclip-reset" type="button">Reset Draft</button></div>
<div class="section-note reset-hint">Structural clipper changes (iterations, knee, look-ahead) are persisted to config and require TX restart.</div>
</div>
</div>
<!-- Danger -->
<div class="card panel" data-panel-key="danger">
<div class="panel-head" data-panel><h2>Danger Zone</h2><div class="meta">emergency</div><span class="chevron">▼</span></div>
@@ -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<ps.length;i++){const p=ps[i];if(!p)continue;if(i===ps.length-1){c[p]=v;return;}if(!c[p]||typeof c[p]!=='object')c[p]={};c=c[p];}}

function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;}return undefined;}
function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;}
function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;}
function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);}
function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);}

function cfgEq(key,a,b){if(key==='pi')return String(a??'').trim().toUpperCase()===String(b??'').trim().toUpperCase();if(key==='pty'||key==='bs412Enabled')return a===b;return nearEq(a,b,1e-5);}
function cfgEq(key,a,b){if(key==='pi')return String(a??'').trim().toUpperCase()===String(b??'').trim().toUpperCase();if(key==='pty'||key==='bs412Enabled'||key==='compositeClipperEnabled'||key==='compositeClipperIterations')return a===b;return nearEq(a,b,1e-5);}
function cfgSetDirty(key,val){
const f=CFG[key];if(!f)return;
S.cfgDraft[key]=val;
@@ -903,6 +933,7 @@ function _render(){
setText('info-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',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');

// ── 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);


Laden…
Annuleren
Opslaan