diff --git a/docs/API.md b/docs/API.md index fa9a6d8..50e5b51 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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) diff --git a/docs/DSP-CHAIN.md b/docs/DSP-CHAIN.md index 3a287f6..fc4207d 100644 --- a/docs/DSP-CHAIN.md +++ b/docs/DSP-CHAIN.md @@ -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. | diff --git a/internal/control/ui.html b/internal/control/ui.html index 597a745..b6e406b 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -404,7 +404,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-
Limiter
MPX peak protection
+
Limiter
Stereo limiter stage only; hard clips remain active
--
@@ -515,9 +515,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

On-Air Text

Live + Saved
+

On-Air Text

Saved + Runtime
-
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.
+
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.
@@ -539,6 +539,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
+
Saved config: --
Active on-air text: --
@@ -636,7 +637,8 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
PI Code
--
PTY
--
PS
--
-
RadioText
--
+
Active RadioText
--
+
Saved RadioText
--
Pilot
--
RDS Inj.
--
@@ -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 diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 4ee2e82..d8fa6eb 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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) diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index afaa0b2..e8c9fb7 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -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") } }