Procházet zdrojové kódy

fix(ui): clarify limiter behavior and active radio text

Make limiterEnabled control the stereo limiter stage only while leaving hard clipping active, and update UI/docs to distinguish saved RadioText from the active on-air value exposed at runtime.
main
Jan před 1 měsícem
rodič
revize
fc783d291e
5 změnil soubory, kde provedl 39 přidání a 48 odebrání
  1. +4
    -2
      docs/API.md
  2. +2
    -2
      docs/DSP-CHAIN.md
  3. +10
    -7
      internal/control/ui.html
  4. +1
    -1
      internal/offline/generator.go
  5. +22
    -36
      internal/offline/generator_test.go

+ 4
- 2
docs/API.md Zobrazit soubor

@@ -69,6 +69,8 @@ Live engine and driver telemetry. When ingest runtime is configured, this endpoi
"chunksProduced": 12345,
"totalSamples": 1408950000,
"underruns": 0,
"activePS": "MYRADIO",
"activeRadioText": "Artist - Song Title",
"lastError": "",
"uptimeSeconds": 3614.2,
"faultCount": 2,
@@ -204,7 +206,7 @@ The control snapshot (GET /config) only reflects new values once they pass valid
| `pilotLevel` | float | 0–0.2 | 19 kHz pilot injection level. |
| `rdsInjection` | float | 0–0.15 | 57 kHz RDS subcarrier injection level. |
| `rdsEnabled` | bool | | Enable/disable RDS subcarrier. |
| `limiterEnabled` | bool | | Enable/disable MPX peak limiter. |
| `limiterEnabled` | bool | | Enable/disable the stereo limiter stage in the L/R path. Hard clip stages remain active. |
| `limiterCeiling` | float | 0–2 | Limiter ceiling (max composite amplitude). |

#### Patchable fields — RDS text (applied within ~88ms)
@@ -214,7 +216,7 @@ The control snapshot (GET /config) only reflects new values once they pass valid
| `ps` | string | 8 chars | Program Service name (station name on receiver display). |
| `radioText` | string | 64 chars | RadioText message (scrolling text on receiver). |

When `radioText` is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display.
When `radioText` is updated, the RDS A/B flag toggles automatically per spec, signaling receivers to refresh their display. If StreamTitle relay is active, the runtime `engine.activeRadioText` exposed by `/runtime` can temporarily differ from the saved `/config` value.

#### Patchable fields — other (saved, not live-applied)



+ 2
- 2
docs/DSP-CHAIN.md Zobrazit soubor

@@ -138,8 +138,8 @@ Architektur und ITU-R BS.412 MPX Power Limiting.

| Parameter | Typ | Default | Bereich | Beschreibung |
|---|---|---|---|---|
| `outputDrive` | float | 0.5 | 0–10 | Eingangsverstärkung vor Limiter/Clip. Bestimmt wie aggressiv die Kompression arbeitet. |
| `limiterEnabled` | bool | true | — | Aktiviert den StereoLimiter (5ms/200ms). |
| `outputDrive` | float | 0.5 | 0–10 | Eingangsverstärkung vor Limiter/Clip. Bestimmt wie aggressiv Limiter und nachfolgende Clip-Stufen arbeiten. |
| `limiterEnabled` | bool | true | — | Aktiviert nur die StereoLimiter-Stufe (5ms/200ms) im L/R-Pfad. Die Hard-Clip-Stufen bleiben aktiv. |
| `limiterCeiling` | float | 1.0 | 0–2 | Maximum-Amplitude für Audio L/R und Composite. 1.0 = ±75kHz. |
| `preEmphasisTauUS` | float | 50 | 0/50/75 | Pre-Emphasis Zeitkonstante. 50µs = Europa/CH, 75µs = USA, 0 = aus. |



+ 10
- 7
internal/control/ui.html Zobrazit soubor

@@ -404,7 +404,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
<div class="toggle-ctl"><select id="sel-stereo-mode" style="font-size:13px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg)"><option value="DSB">DSB-SC (Standard)</option><option value="SSB">SSB-SC LSB</option><option value="VSB">VSB (Vestigial)</option></select></div>
</div>
<div class="toggle-row">
<div class="toggle-copy"><div class="title">Limiter</div><div class="sub">MPX peak protection</div></div>
<div class="toggle-copy"><div class="title">Limiter</div><div class="sub">Stereo limiter stage only; hard clips remain active</div></div>
<div class="toggle-ctl"><div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="limiter-label">--</div></div>
</div>
</div>
@@ -515,9 +515,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
</div>
<!-- On-Air Text -->
<div class="card panel" data-panel-key="rds-text">
<div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>On-Air Text</h2><div class="meta" id="rds-text-meta">Live + Saved</div><span class="chevron">▼</span></div>
<div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>On-Air Text</h2><div class="meta" id="rds-text-meta">Saved + Runtime</div><span class="chevron">▼</span></div>
<div class="panel-body">
<div class="section-note">PS and RadioText apply at the next RDS group boundary (~88ms). Edits stay local until you apply, then update the live encoder and config snapshot together.</div>
<div class="section-note">PS and RadioText apply at the next RDS group boundary (~88ms). Edits stay local until you apply, then update the live encoder and config snapshot together. When StreamTitle relay is enabled, the active on-air RadioText can temporarily differ from the saved config value shown in the editor.</div>
<div class="preset-row">
<button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
<button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
@@ -539,6 +539,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
</div>
</div>
<div class="actions-row"><button class="apply-btn" id="rds-apply" type="button">Apply + Save RDS Text</button><button class="apply-btn secondary" id="rds-reset" type="button">Reset Draft</button></div>
<div class="section-note">Saved config: <span id="rds-saved-rt">--</span><br>Active on-air text: <span id="rds-active-rt-inline">--</span></div>
</div>
</div>
<!-- RDS Features -->
@@ -636,7 +637,8 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
<div class="k">PI Code</div><div class="v" id="rds-stat-pi" style="font-family:var(--mono);font-weight:700">--</div>
<div class="k">PTY</div><div class="v" id="rds-stat-pty">--</div>
<div class="k">PS</div><div class="v" id="rds-stat-ps" style="font-family:var(--mono);font-weight:700;letter-spacing:1px">--</div>
<div class="k">RadioText</div><div class="v" id="rds-stat-rt">--</div>
<div class="k">Active RadioText</div><div class="v" id="rds-stat-rt">--</div>
<div class="k">Saved RadioText</div><div class="v" id="rds-stat-rt-saved">--</div>
<div class="k">Pilot</div><div class="v" id="rds-stat-pilot">--</div>
<div class="k">RDS Inj.</div><div class="v" id="rds-stat-inj">--</div>
</div>
@@ -1014,7 +1016,7 @@ function _render(){
setText('info-pi',fmtPI(cfg.rds?.pi));setText('info-pty',fmtPTY(cfg.rds?.pty));
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?`ON (ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)})`:'ON'):'OFF');
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-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--');
setText('info-bs412',cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF');
@@ -1073,7 +1075,7 @@ function _render(){
syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??''));
const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??'');
setText('ps-count',psV.length);setText('rt-count',rtV.length);
const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Live + Saved');
const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Saved + Runtime');
$('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD;
const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);}
const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);}
@@ -1102,8 +1104,9 @@ function _render(){
// Status card
const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
const savedRT=String(cfg.rds?.radioText||'').trim();
setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');
setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');setText('rds-stat-rt-saved',savedRT||'--');setText('rds-saved-rt',savedRT||'--');setText('rds-active-rt-inline',activeRT||'--');
setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));

// ── Ingest tab


+ 1
- 1
internal/offline/generator.go Zobrazit soubor

@@ -482,7 +482,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
// --- Stage 2: Drive + Compress + Clip₁ ---
l *= lp.OutputDrive
r *= lp.OutputDrive
if g.limiter != nil {
if lp.LimiterEnabled && g.limiter != nil {
l, r = g.limiter.Process(l, r)
}
l = dsp.HardClip(l, ceiling)


+ 22
- 36
internal/offline/generator_test.go Zobrazit soubor

@@ -160,47 +160,33 @@ func TestFMModDisabledMeansComposite(t *testing.T) {
}
}

func TestClipFilterClipAlwaysActive(t *testing.T) {
// With clip-filter-clip architecture, peak control is always active
// regardless of LimiterEnabled (legacy flag). Both configs should
// produce the same peak level.
func TestLimiterEnabledChangesWaveform(t *testing.T) {
base := cfgpkg.Default()
base.FM.FMModulationEnabled = false
base.Audio.ToneAmplitude = 0.9
base.Audio.Gain = 2.0
base.FM.OutputDrive = 1.0

cfgA := base
cfgA.FM.LimiterEnabled = true
cfgA.FM.LimiterCeiling = 1.0
cfgB := base
cfgB.FM.LimiterEnabled = false
cfgB.FM.LimiterCeiling = 1.0

fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond)
fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond)

var maxA, maxB float64
for _, s := range fA.Samples {
if math.Abs(float64(s.I)) > maxA {
maxA = math.Abs(float64(s.I))
}
}
for _, s := range fB.Samples {
if math.Abs(float64(s.I)) > maxB {
maxB = math.Abs(float64(s.I))
}
base.Audio.ToneAmplitude = 0.95
base.Audio.Gain = 3.0
base.FM.OutputDrive = 2.5
base.FM.LimiterCeiling = 0.8

cfgOn := base
cfgOn.FM.LimiterEnabled = true
cfgOff := base
cfgOff.FM.LimiterEnabled = false

fOn := NewGenerator(cfgOn).GenerateFrame(50 * time.Millisecond)
fOff := NewGenerator(cfgOff).GenerateFrame(50 * time.Millisecond)

if len(fOn.Samples) != len(fOff.Samples) {
t.Fatalf("sample length mismatch: %d vs %d", len(fOn.Samples), len(fOff.Samples))
}

// Both should be within ceiling + pilot + RDS
maxAllowed := cfgA.FM.LimiterCeiling +
cfgA.FM.PilotLevel*cfgA.FM.OutputDrive +
cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02
if maxA > maxAllowed {
t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed)
var diffEnergy float64
for i := range fOn.Samples {
d := float64(fOn.Samples[i].I - fOff.Samples[i].I)
diffEnergy += d * d
}
if maxB > maxAllowed {
t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed)
if diffEnergy == 0 {
t.Fatal("expected limiterEnabled to change waveform")
}
}



Načítá se…
Zrušit
Uložit