|
|
|
@@ -77,13 +77,24 @@ type Generator struct { |
|
|
|
stereoEncoder stereo.StereoEncoder |
|
|
|
rdsEnc *rds.Encoder |
|
|
|
combiner mpx.DefaultCombiner |
|
|
|
limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding |
|
|
|
lpfL, lpfR *dsp.BiquadLPF // 15kHz lowpass after limiter, protects RDS band |
|
|
|
limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding |
|
|
|
fmMod *dsp.FMModulator |
|
|
|
sampleRate float64 |
|
|
|
initialized bool |
|
|
|
frameSeq uint64 |
|
|
|
|
|
|
|
// Broadcast-standard audio filter chain (per channel, L and R): |
|
|
|
// Pre-emphasis → 15kHz LPF (4th-order) → 19kHz Notch → Drive → Limiter |
|
|
|
audioLPF_L *dsp.FilterChain // 4th-order Butterworth 15kHz |
|
|
|
audioLPF_R *dsp.FilterChain |
|
|
|
pilotNotchL *dsp.Biquad // 19kHz notch (guard band protection) |
|
|
|
pilotNotchR *dsp.Biquad |
|
|
|
|
|
|
|
// Composite clipper protection (post-clip notch filters): |
|
|
|
// Audio composite → clip → notch 19kHz → notch 57kHz → + pilot → + RDS |
|
|
|
mpxNotch19 *dsp.Biquad // removes clip harmonics at pilot freq |
|
|
|
mpxNotch57 *dsp.Biquad // removes clip harmonics at RDS freq |
|
|
|
|
|
|
|
// Pre-allocated frame buffer — reused every GenerateFrame call. |
|
|
|
frameBuf *output.CompositeFrame |
|
|
|
bufCap int |
|
|
|
@@ -160,10 +171,16 @@ func (g *Generator) init() { |
|
|
|
if g.cfg.FM.LimiterEnabled { |
|
|
|
g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate) |
|
|
|
} |
|
|
|
// 15kHz lowpass after limiter — removes limiter gain-step intermodulation |
|
|
|
// products that would otherwise fall into pilot/stereo/RDS bands. |
|
|
|
g.lpfL = dsp.NewBiquadLPF(15000, g.sampleRate) |
|
|
|
g.lpfR = dsp.NewBiquadLPF(15000, g.sampleRate) |
|
|
|
|
|
|
|
// Broadcast-standard filter chain: |
|
|
|
// 1) 15kHz 4th-order Butterworth LPF — steep guard band, -14dB@19kHz, -54dB@57kHz |
|
|
|
g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) |
|
|
|
g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) |
|
|
|
// 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection |
|
|
|
g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) |
|
|
|
g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) |
|
|
|
// 3) Composite clipper protection notches at 19kHz + 57kHz |
|
|
|
g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) |
|
|
|
if g.cfg.FM.FMModulationEnabled { |
|
|
|
g.fmMod = dsp.NewFMModulator(g.sampleRate) |
|
|
|
if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } |
|
|
|
@@ -223,52 +240,71 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} |
|
|
|
} |
|
|
|
|
|
|
|
// Signal path (matches professional broadcast processors): |
|
|
|
// Audio L/R → × Drive → Stereo-linked limiter → Stereo encoder |
|
|
|
// → Mono + Stereo sub (from limited audio, natural levels) |
|
|
|
// → + Pilot (fixed) → + RDS (fixed) → FM modulator |
|
|
|
// Broadcast-standard FM MPX signal chain: |
|
|
|
// |
|
|
|
// The limiter never sees the 38kHz subcarrier, so it can't pump |
|
|
|
// the stereo difference signal. Pilot and RDS are post-encoder |
|
|
|
// at fixed amplitudes, unaffected by audio dynamics. |
|
|
|
// Audio L/R |
|
|
|
// → PreEmphasis (50µs EU / 75µs US) |
|
|
|
// → 15kHz LPF (4th-order Butterworth, -14dB@19kHz, -54dB@57kHz) |
|
|
|
// → 19kHz Notch (>40dB rejection, guard band protection) |
|
|
|
// → × OutputDrive |
|
|
|
// → StereoLimiter (instant attack, smooth release) |
|
|
|
// → Stereo Encode → Mono (L+R)/2 + Stereo Sub (L-R)/2 × 38kHz |
|
|
|
// Audio MPX composite |
|
|
|
// → HardClip at audioCeiling (catches limiter overshoots) |
|
|
|
// → 19kHz Notch (removes clip harmonics at pilot freq) |
|
|
|
// → 57kHz Notch (removes clip harmonics at RDS freq) |
|
|
|
// + Pilot 19kHz (fixed amplitude, post-clip) |
|
|
|
// + RDS 57kHz (fixed amplitude, post-clip) |
|
|
|
// → FM Modulator |
|
|
|
// |
|
|
|
// Audio ceiling is auto-reduced to leave headroom for pilot + RDS, |
|
|
|
// so total composite stays within ±ceiling (= ±75kHz deviation). |
|
|
|
// Key: Pilot and RDS are NEVER clipped or filtered. They're added |
|
|
|
// after all audio processing at constant amplitude. |
|
|
|
ceiling := lp.LimiterCeiling |
|
|
|
if ceiling <= 0 { ceiling = 1.0 } |
|
|
|
pilotAmp := lp.PilotLevel * lp.OutputDrive |
|
|
|
rdsAmp := lp.RDSInjection * lp.OutputDrive |
|
|
|
audioCeiling := ceiling - pilotAmp - rdsAmp |
|
|
|
if audioCeiling < 0.3 { audioCeiling = 0.3 } // safety floor |
|
|
|
if audioCeiling < 0.3 { audioCeiling = 0.3 } |
|
|
|
|
|
|
|
for i := 0; i < samples; i++ { |
|
|
|
in := g.source.NextFrame() |
|
|
|
|
|
|
|
// --- Stage 1: Band-limit pre-emphasized audio --- |
|
|
|
// The 15kHz LPF goes BEFORE drive+limiter. Pre-emphasis boosts |
|
|
|
// HF by up to +13.5dB. Without the LPF, the limiter would waste |
|
|
|
// gain reduction on HF peaks that get filtered later, causing |
|
|
|
// wild modulation swings (30-163%). With LPF first, the limiter |
|
|
|
// sees the final audio bandwidth and sets gain correctly. |
|
|
|
l := g.lpfL.Process(float64(in.L)) |
|
|
|
r := g.lpfR.Process(float64(in.R)) |
|
|
|
|
|
|
|
// --- Stage 2: Scale and limit --- |
|
|
|
// --- Stage 1: Audio filtering (per-channel) --- |
|
|
|
// 15kHz LPF removes out-of-band pre-emphasis energy. |
|
|
|
// 19kHz notch kills residual energy at pilot frequency. |
|
|
|
// Both run BEFORE drive+limiter so the limiter sees the |
|
|
|
// actual audio bandwidth, not wasted HF energy. |
|
|
|
l := g.audioLPF_L.Process(float64(in.L)) |
|
|
|
l = g.pilotNotchL.Process(l) |
|
|
|
r := g.audioLPF_R.Process(float64(in.R)) |
|
|
|
r = g.pilotNotchR.Process(r) |
|
|
|
|
|
|
|
// --- Stage 2: Drive + Limit --- |
|
|
|
l *= lp.OutputDrive |
|
|
|
r *= lp.OutputDrive |
|
|
|
|
|
|
|
if lp.LimiterEnabled && g.limiter != nil { |
|
|
|
l, r = g.limiter.Process(l, r) |
|
|
|
} |
|
|
|
|
|
|
|
// --- Stage 3: Stereo encode the limited, filtered audio --- |
|
|
|
// --- Stage 3: Stereo encode --- |
|
|
|
limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) |
|
|
|
comps := g.stereoEncoder.Encode(limited) |
|
|
|
|
|
|
|
// --- Stage 3: Combine at fixed levels --- |
|
|
|
composite := float64(comps.Mono) |
|
|
|
// --- Stage 4: Audio composite clip + protection --- |
|
|
|
// Clip the audio-only composite (mono + stereo sub) to budget. |
|
|
|
// Then notch-filter the clip harmonics out of the pilot (19kHz) |
|
|
|
// and RDS (57kHz) bands before adding the real pilot and RDS. |
|
|
|
audioMPX := float64(comps.Mono) |
|
|
|
if lp.StereoEnabled { |
|
|
|
audioMPX += float64(comps.Stereo) |
|
|
|
} |
|
|
|
audioMPX = dsp.HardClip(audioMPX, audioCeiling) |
|
|
|
audioMPX = g.mpxNotch19.Process(audioMPX) |
|
|
|
audioMPX = g.mpxNotch57.Process(audioMPX) |
|
|
|
|
|
|
|
// --- Stage 5: Add protected components at fixed levels --- |
|
|
|
composite := audioMPX |
|
|
|
if lp.StereoEnabled { |
|
|
|
composite += float64(comps.Stereo) |
|
|
|
composite += pilotAmp * comps.Pilot |
|
|
|
} |
|
|
|
if g.rdsEnc != nil && lp.RDSEnabled { |
|
|
|
@@ -277,13 +313,6 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
composite += rdsAmp * rdsValue |
|
|
|
} |
|
|
|
|
|
|
|
// Final composite safety clip — only fires on brief limiter |
|
|
|
// overshoots during fast transients. Clips the entire composite, |
|
|
|
// not individual audio bands, so harmonics don't target RDS. |
|
|
|
if lp.LimiterEnabled { |
|
|
|
composite = dsp.HardClip(composite, ceiling) |
|
|
|
} |
|
|
|
|
|
|
|
if g.fmMod != nil { |
|
|
|
iq_i, iq_q := g.fmMod.Modulate(composite) |
|
|
|
frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)} |
|
|
|
|