package offline import ( "context" "encoding/binary" "fmt" "log" "math" "path/filepath" "sync/atomic" "time" "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/dsp" "github.com/jan/fm-rds-tx/internal/license" "github.com/jan/fm-rds-tx/internal/mpx" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/rds" "github.com/jan/fm-rds-tx/internal/stereo" "github.com/jan/fm-rds-tx/internal/watermark" ) type frameSource interface { NextFrame() audio.Frame } // LiveParams carries DSP parameters that can be hot-swapped at runtime. // Loaded once per chunk via atomic pointer — zero per-sample overhead. type LiveParams struct { OutputDrive float64 StereoEnabled bool StereoMode string PilotLevel float64 RDSInjection float64 RDSEnabled bool LimiterEnabled bool LimiterCeiling float64 MpxGain float64 // hardware calibration factor for composite output // Tone + gain: live-patchable without DSP chain reinit. ToneLeftHz float64 ToneRightHz float64 ToneAmplitude float64 AudioGain float64 // Composite clipper: live-toggleable without DSP chain reinit. CompositeClipperEnabled bool } // PreEmphasizedSource wraps an audio source and applies pre-emphasis. // The source is expected to already output at composite rate (resampled // upstream). Pre-emphasis is applied per-sample at that rate. type PreEmphasizedSource struct { src frameSource preL *dsp.PreEmphasis preR *dsp.PreEmphasis gain float64 } func NewPreEmphasizedSource(src frameSource, tauUS, sampleRate, gain float64) *PreEmphasizedSource { p := &PreEmphasizedSource{src: src, gain: gain} if tauUS > 0 { p.preL = dsp.NewPreEmphasis(tauUS, sampleRate) p.preR = dsp.NewPreEmphasis(tauUS, sampleRate) } return p } func (p *PreEmphasizedSource) NextFrame() audio.Frame { f := p.src.NextFrame() l := float64(f.L) * p.gain r := float64(f.R) * p.gain if p.preL != nil { l = p.preL.Process(l) r = p.preR.Process(r) } return audio.NewFrame(audio.Sample(l), audio.Sample(r)) } type SourceInfo struct { Kind string SampleRate float64 Detail string } type MeasurementFlags struct { StereoEnabled bool `json:"stereoEnabled"` StereoMode string `json:"stereoMode"` RDSEnabled bool `json:"rdsEnabled"` RDS2Enabled bool `json:"rds2Enabled"` BS412Enabled bool `json:"bs412Enabled"` CompositeClipperEnabled bool `json:"compositeClipperEnabled"` WatermarkEnabled bool `json:"watermarkEnabled"` LicenseInjectionActive bool `json:"licenseInjectionActive"` } type LRPreEncodePostWatermarkMeasurement struct { LRms float64 `json:"lRms"` RRms float64 `json:"rRms"` LPeakAbs float64 `json:"lPeakAbs"` RPeakAbs float64 `json:"rPeakAbs"` LRBalanceDB float64 `json:"lrBalanceDb"` LClipEvents uint32 `json:"lClipEvents"` RClipEvents uint32 `json:"rClipEvents"` } type AudioMPXPreBS412Measurement struct { RMS float64 `json:"rms"` PeakAbs float64 `json:"peakAbs"` MonoRMS float64 `json:"monoRms"` StereoRMS float64 `json:"stereoRms"` CrestFactor float64 `json:"crestFactor"` ClipperLookaheadGain float64 `json:"clipperLookaheadGain"` ClipperEnvelope float64 `json:"clipperEnvelope"` ClipperOrProtectionActive bool `json:"clipperOrProtectionActive"` } type AudioMPXPostBS412Measurement struct { RMS float64 `json:"rms"` PeakAbs float64 `json:"peakAbs"` BS412GainApplied float64 `json:"bs412GainApplied"` BS412AttenuationDB float64 `json:"bs412AttenuationDb"` EstimatedAudioPower float64 `json:"estimatedAudioPower"` } type CompositeFinalPreIQMeasurement struct { RMS float64 `json:"rms"` PeakAbs float64 `json:"peakAbs"` PilotRMS float64 `json:"pilotRms"` PilotPeakAbs float64 `json:"pilotPeakAbs"` PilotInjectionEquivalentPercent float64 `json:"pilotInjectionEquivalentPercent"` RDSRMS float64 `json:"rdsRms"` RDSPeakAbs float64 `json:"rdsPeakAbs"` OverNominalEvents uint32 `json:"overNominalEvents"` OverHeadroomEvents uint32 `json:"overHeadroomEvents"` } type MeasurementSnapshot struct { Timestamp time.Time `json:"timestamp"` SampleRateHz float64 `json:"sampleRateHz"` ChunkSamples int `json:"chunkSamples"` ChunkDurationMs float64 `json:"chunkDurationMs"` Sequence uint64 `json:"sequence"` Stale bool `json:"stale"` NoData bool `json:"noData"` Flags MeasurementFlags `json:"flags"` LRPreEncodePostWatermark LRPreEncodePostWatermarkMeasurement `json:"lrPreEncodePostWatermark"` AudioMPXPreBS412 AudioMPXPreBS412Measurement `json:"audioMpxPreBs412"` AudioMPXPostBS412 AudioMPXPostBS412Measurement `json:"audioMpxPostBs412"` CompositeFinalPreIQ CompositeFinalPreIQMeasurement `json:"compositeFinalPreIq"` } type Generator struct { cfg cfgpkg.Config // Persistent DSP state across GenerateFrame calls source *PreEmphasizedSource stereoEncoder stereo.StereoEncoder appliedStereoMode string // canonical mode currently applied to stereoEncoder; DSP goroutine only rdsEnc *rds.Encoder rds2Enc *rds.RDS2Encoder combiner mpx.DefaultCombiner fmMod *dsp.FMModulator sampleRate float64 initialized bool frameSeq uint64 // Broadcast-standard clip-filter-clip chain (per channel L/R): // // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive // → StereoLimiter (slow AGC: raises average level) // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots] // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ // → + Pilot → + RDS → FM // audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip) audioLPF_R *dsp.FilterChain pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band) pilotNotchR *dsp.FilterChain limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks) cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup) cleanupLPF_R *dsp.FilterChain mpxNotch19 *dsp.FilterChain // composite clipper protection mpxNotch57 *dsp.FilterChain bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional) compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional) // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame bufCap int // Live-updatable DSP parameters — written by control API, read per chunk. liveParams atomic.Pointer[LiveParams] // Optional external audio source (e.g. StreamResampler for live audio). // When set, takes priority over WAV/tones in sourceFor(). externalSource frameSource // Tone source reference — non-nil when a ToneSource is the active audio input. // Allows live-updating tone parameters via LiveParams each chunk. toneSource *audio.ToneSource // License: jingle injection when unlicensed. licenseState *license.State jingleFrames []license.JingleFrame // Watermark: explicit opt-in STFT-domain spread-spectrum (Kirovski & Malvar 2003). watermarkEnabled bool watermarkKey string stftEmbedder *watermark.STFTEmbedder wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample latestMeasurement atomic.Pointer[MeasurementSnapshot] } func NewGenerator(cfg cfgpkg.Config) *Generator { return &Generator{cfg: cfg} } // SetLicense configures license state for evaluation-jingle behavior only. // It intentionally does not enable or instantiate the audio watermark path. func (g *Generator) SetLicense(state *license.State) { g.licenseState = state } // ConfigureWatermark explicitly enables or disables the optional STFT watermark. // Must be called before the first GenerateFrame. func (g *Generator) ConfigureWatermark(enabled bool, key string) { g.watermarkEnabled = enabled g.watermarkKey = key if !enabled { g.stftEmbedder = nil g.wmDecimLPF = nil g.wmInterpLPF = nil return } g.stftEmbedder = watermark.NewSTFTEmbedder(key) } // SetExternalSource sets a live audio source (e.g. StreamResampler) that // takes priority over WAV/tone sources. Must be called before the first // GenerateFrame() call; calling it after init() has no effect because // g.source is already wired to the old source. func (g *Generator) SetExternalSource(src frameSource) error { if g.initialized { return fmt.Errorf("generator: SetExternalSource called after GenerateFrame; call it before the engine starts") } g.externalSource = src return nil } // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API. // The DSP goroutine applies mode changes at the next chunk boundary. func (g *Generator) UpdateLive(p LiveParams) { p.StereoMode = canonicalStereoMode(p.StereoMode) g.liveParams.Store(&p) } // CurrentLiveParams returns the current live parameter snapshot. // Used by Engine.UpdateConfig to do read-modify-write on the params. func (g *Generator) CurrentLiveParams() LiveParams { if lp := g.liveParams.Load(); lp != nil { return *lp } return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } func canonicalStereoMode(mode string) string { return stereo.ParseMode(mode).String() } // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled. // Used by the Engine to forward text updates. func (g *Generator) RDSEncoder() *rds.Encoder { return g.rdsEnc } func (g *Generator) LatestMeasurement() *MeasurementSnapshot { if m := g.latestMeasurement.Load(); m != nil { copy := *m return © } return nil } func (g *Generator) resetSource() { rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) } func (g *Generator) resetRDS2Encoder() { if !g.cfg.RDS.Enabled || !g.cfg.RDS.RDS2Enabled { g.rds2Enc = nil return } g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate) g.rds2Enc.Enable(true) if g.cfg.RDS.StationLogoPath != "" { if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil { log.Printf("rds2: failed to load station logo: %v", err) } } } // Reset clears stateful DSP/runtime state so the next run starts from a clean baseline // without changing the current live parameters or feature enablement. func (g *Generator) Reset() { if !g.initialized { return } g.resetSource() mode := g.appliedStereoMode if lp := g.liveParams.Load(); lp != nil { mode = canonicalStereoMode(lp.StereoMode) } g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate) g.appliedStereoMode = mode if g.rdsEnc != nil { g.rdsEnc.Reset() } g.resetRDS2Encoder() if g.audioLPF_L != nil { g.audioLPF_L.Reset() } if g.audioLPF_R != nil { g.audioLPF_R.Reset() } if g.pilotNotchL != nil { g.pilotNotchL.Reset() } if g.pilotNotchR != nil { g.pilotNotchR.Reset() } if g.limiter != nil { g.limiter.Reset() } if g.cleanupLPF_L != nil { g.cleanupLPF_L.Reset() } if g.cleanupLPF_R != nil { g.cleanupLPF_R.Reset() } if g.mpxNotch19 != nil { g.mpxNotch19.Reset() } if g.mpxNotch57 != nil { g.mpxNotch57.Reset() } if g.bs412 != nil { g.bs412.Reset() } if g.compositeClip != nil { g.compositeClip.Reset() } if g.fmMod != nil { g.fmMod.Reset() } if g.watermarkEnabled { g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey) g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate) g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) } else { g.stftEmbedder = nil g.wmDecimLPF = nil g.wmInterpLPF = nil } } func (g *Generator) init() { if g.initialized { return } g.sampleRate = float64(g.cfg.FM.CompositeRateHz) if g.sampleRate <= 0 { g.sampleRate = 228000 } if g.watermarkEnabled { if g.stftEmbedder == nil { g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey) } } else { g.stftEmbedder = nil g.wmDecimLPF = nil g.wmInterpLPF = nil } rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) g.appliedStereoMode = canonicalStereoMode(g.cfg.FM.StereoMode) g.stereoEncoder.SetMode(stereo.ParseMode(g.appliedStereoMode), g.sampleRate) g.combiner = mpx.DefaultCombiner{ MonoGain: 1.0, StereoGain: 1.0, PilotGain: g.cfg.FM.PilotLevel, RDSGain: g.cfg.FM.RDSInjection, } if g.cfg.RDS.Enabled { piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI) // Build EON entries var eonEntries []rds.EONEntry for _, e := range g.cfg.RDS.EON { eonPI, _ := cfgpkg.ParsePI(e.PI) eonEntries = append(eonEntries, rds.EONEntry{ PI: eonPI, PS: e.PS, PTY: uint8(e.PTY), TP: e.TP, TA: e.TA, AF: e.AF, }) } sep := g.cfg.RDS.RTPlusSeparator if sep == "" { sep = " - " } g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{ PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText, PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate, TP: g.cfg.RDS.TP, TA: g.cfg.RDS.TA, MS: g.cfg.RDS.MS, DI: g.cfg.RDS.DI, AF: g.cfg.RDS.AF, CTEnabled: g.cfg.RDS.CTEnabled, CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours, PTYN: g.cfg.RDS.PTYN, LPS: g.cfg.RDS.LPS, RTPlusEnabled: g.cfg.RDS.RTPlusEnabled, RTPlusSeparator: sep, ERTEnabled: g.cfg.RDS.ERTEnabled, ERT: g.cfg.RDS.ERT, ERTGroupType: 12, // default: Group 12A EON: eonEntries, }) // RDS2: additional subcarriers (66.5, 71.25, 76 kHz) g.resetRDS2Encoder() } ceiling := g.cfg.FM.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } // Broadcast clip-filter-clip chain: // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel) g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) // Slow compressor: 5ms attack / 200ms release. Brings average level UP. // The clips after it catch the peaks the limiter's attack time misses. // This is the "slow-to-fast progression" from broadcast processing: // slow limiter → fast clips. // Burst-masking-optimized limiter (Bonello, JAES 2007): // 2ms attack lets initial transient peaks clip for <5ms (burst-masked). // 150ms release avoids audible pumping on sustained passages. g.limiter = dsp.NewStereoLimiter(ceiling, 2, 150, g.sampleRate) // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics) g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate) g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) // Composite clipper protection: double-notch at 19kHz + 57kHz g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) // ITU-R SM.1268 iterative composite clipper (optional, replaces simple clip+notch) // Always created so it can be live-toggled via CompositeClipperEnabled. g.compositeClip = dsp.NewCompositeClipper(dsp.CompositeClipperConfig{ Ceiling: ceiling, Iterations: g.cfg.FM.CompositeClipper.Iterations, SoftKnee: g.cfg.FM.CompositeClipper.SoftKnee, LookaheadMs: g.cfg.FM.CompositeClipper.LookaheadMs, SampleRate: g.sampleRate, }) // BS.412 MPX power limiter (EU/CH requirement for licensed FM) if g.cfg.FM.BS412Enabled { // chunkSec is not known at init time (Engine.chunkDuration may differ). // Pass 0 here; GenerateFrame computes the actual chunk duration from // the real sample count and updates BS.412 accordingly. g.bs412 = dsp.NewBS412Limiter( g.cfg.FM.BS412ThresholdDBr, g.cfg.FM.PilotLevel, g.cfg.FM.RDSInjection, 0, ) } if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) maxDev := g.cfg.FM.MaxDeviationHz if maxDev > 0 { if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 { maxDev *= g.cfg.FM.MpxGain } g.fmMod.MaxDeviation = maxDev } } // Seed initial live params from config g.liveParams.Store(&LiveParams{ OutputDrive: g.cfg.FM.OutputDrive, StereoEnabled: g.cfg.FM.StereoEnabled, StereoMode: g.appliedStereoMode, PilotLevel: g.cfg.FM.PilotLevel, RDSInjection: g.cfg.FM.RDSInjection, RDSEnabled: g.cfg.RDS.Enabled, LimiterEnabled: g.cfg.FM.LimiterEnabled, LimiterCeiling: ceiling, MpxGain: g.cfg.FM.MpxGain, ToneLeftHz: g.cfg.Audio.ToneLeftHz, ToneRightHz: g.cfg.Audio.ToneRightHz, ToneAmplitude: g.cfg.Audio.ToneAmplitude, AudioGain: g.cfg.Audio.Gain, CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled, }) if g.licenseState != nil { frames, err := license.LoadJingleFrames(license.JingleWAV(), g.sampleRate) if err != nil { log.Printf("license: jingle load failed: %v", err) } else { g.jingleFrames = frames } } // STFT watermark: anti-alias LPF for decimation to WMRate (12 kHz). // Nyquist at 12 kHz = 6 kHz. Cut at 5.5 kHz with margin. if g.stftEmbedder != nil { g.wmDecimLPF = dsp.NewLPF4(5500, g.sampleRate) g.wmInterpLPF = dsp.NewLPF4(5500, g.sampleRate) // separate instance for upsample } g.initialized = true } func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { if g.externalSource != nil { return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"} } if g.cfg.Audio.InputPath != "" { if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} } ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) g.toneSource = ts return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} } ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) g.toneSource = ts return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} } func clamp01(v float64) float64 { if v < 0 { return 0 } if v > 1 { return 1 } return v } func safeRMS(sumSquares float64, n int) float64 { if n <= 0 { return 0 } return math.Sqrt(sumSquares / float64(n)) } func safeDBRatio(a, b float64) float64 { if a <= 0 || b <= 0 { return 0 } return 20 * math.Log10(a/b) } func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { g.init() samples := int(duration.Seconds() * g.sampleRate) if samples <= 0 { samples = int(g.sampleRate / 10) } // Reuse buffer — grow only if needed, never shrink if g.frameBuf == nil || g.bufCap < samples { g.frameBuf = &output.CompositeFrame{ Samples: make([]output.IQSample, samples), } g.bufCap = samples } frame := g.frameBuf frame.Samples = frame.Samples[:samples] frame.SampleRateHz = g.sampleRate frame.Timestamp = time.Now().UTC() g.frameSeq++ frame.Sequence = g.frameSeq // L/R buffers for two-pass processing (STFT watermark between stages 3 and 4) lBuf := make([]float64, samples) rBuf := make([]float64, samples) var lrLSumSq, lrRSumSq float64 var lrLPeak, lrRPeak float64 var lrLClip, lrRClip uint32 var preMonoSumSq, preStereoSumSq, preAudioMpxSumSq float64 var preAudioMpxPeak float64 var postAudioMpxSumSq float64 var postAudioMpxPeak float64 var finalCompositeSumSq float64 var finalCompositePeak float64 var pilotSumSq, rdsSumSq float64 var pilotPeak, rdsPeak float64 var overNominal, overHeadroom uint32 licenseInjectionActive := false // Load live params once per chunk — single atomic read, zero per-sample cost lp := g.liveParams.Load() if lp == nil { // Fallback: should never happen after init(), but be safe lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } if mode := canonicalStereoMode(lp.StereoMode); mode != g.appliedStereoMode { g.stereoEncoder.SetMode(stereo.ParseMode(mode), g.sampleRate) g.appliedStereoMode = mode } // Apply live tone and gain updates each chunk. GenerateFrame runs on a // single goroutine so these field writes are safe without additional locking. if g.toneSource != nil { g.toneSource.LeftFreq = lp.ToneLeftHz g.toneSource.RightFreq = lp.ToneRightHz g.toneSource.Amplitude = lp.ToneAmplitude } if g.source != nil { g.source.gain = lp.AudioGain } // Broadcast clip-filter-clip FM MPX signal chain: // // Audio L/R → PreEmphasis // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double) // → × OutputDrive → HardClip₁ (ceiling) // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics] // → HardClip₂ (ceiling) [catches LPF₂ overshoots] // → Stereo Encode // Audio MPX (mono + stereo sub) // → HardClip₃ (ceiling) [composite deviation control] // → 19kHz Notch (double) [protect pilot band] // → 57kHz Notch (double) [protect RDS band] // + Pilot 19kHz (fixed, NEVER clipped) // + RDS 57kHz (fixed, NEVER clipped) // → FM Modulator // // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB) // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB ceiling := lp.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } // Pilot and RDS are FIXED injection levels, independent of OutputDrive. // Config values directly represent percentage of ±75kHz deviation: // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) // rdsInjection: 0.04 = 4% = ±3.0kHz (typical) pilotAmp := lp.PilotLevel rdsAmp := lp.RDSInjection // BS.412 MPX power limiter: uses previous chunk's measurement to set gain. // Power is measured during this chunk and fed back at the end. bs412Gain := 1.0 var bs412PowerAccum float64 if g.bs412 != nil { bs412Gain = g.bs412.CurrentGain() } if g.licenseState != nil { g.licenseState.Tick() } for i := 0; i < samples; i++ { in := g.source.NextFrame() // --- Stage 1: Band-limit pre-emphasized audio --- 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 + Compress + Clip₁ --- l *= lp.OutputDrive r *= lp.OutputDrive if lp.LimiterEnabled && g.limiter != nil { l, r = g.limiter.Process(l, r) } l = dsp.HardClip(l, ceiling) r = dsp.HardClip(r, ceiling) // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) --- l = g.cleanupLPF_L.Process(l) r = g.cleanupLPF_R.Process(r) l = dsp.HardClip(l, ceiling) r = dsp.HardClip(r, ceiling) lBuf[i] = l rBuf[i] = r } // --- STFT Watermark: decimate → embed → upsample → add to L/R --- if g.stftEmbedder != nil { decimFactor := int(g.sampleRate) / watermark.WMRate // 228000/12000 = 19 if decimFactor < 1 { decimFactor = 1 } nDown := samples / decimFactor // Anti-alias: LPF ALL composite-rate samples, THEN decimate. // The LPF must see every sample for correct IIR state update. mono12k := make([]float64, nDown) lpfState := 0.0 decimCount := 0 outIdx := 0 for i := 0; i < samples && outIdx < nDown; i++ { mono := (lBuf[i] + rBuf[i]) / 2 if g.wmDecimLPF != nil { lpfState = g.wmDecimLPF.Process(mono) } else { lpfState = mono } decimCount++ if decimCount >= decimFactor { decimCount = 0 mono12k[outIdx] = lpfState outIdx++ } } // STFT embed at 12 kHz embedded := g.stftEmbedder.ProcessBlock(mono12k) // Extract watermark signal (difference) and upsample via ZOH + LPF. // ZOH creates spectral images at 12k, 24k, 36k... Hz. // The interpolation LPF removes these, keeping only 0-5.5 kHz. // Without this, the images leak into pilot (19k) and stereo sub (38k). for i := 0; i < samples; i++ { wmIdx := i / decimFactor if wmIdx >= nDown { wmIdx = nDown - 1 } wmSig := embedded[wmIdx] - mono12k[wmIdx] if g.wmInterpLPF != nil { wmSig = g.wmInterpLPF.Process(wmSig) } lBuf[i] += wmSig rBuf[i] += wmSig } } // --- Pass 2: Stereo encode + composite processing --- // Feed RDS2 groups once per frame (not per sample) if g.rds2Enc != nil && g.rds2Enc.Enabled() { g.rds2Enc.FeedGroups() } for i := 0; i < samples; i++ { l := lBuf[i] r := rBuf[i] lrLSumSq += l * l lrRSumSq += r * r if abs := math.Abs(l); abs > lrLPeak { lrLPeak = abs } if abs := math.Abs(r); abs > lrRPeak { lrRPeak = abs } if math.Abs(l) >= ceiling { lrLClip++ } if math.Abs(r) >= ceiling { lrRClip++ } // --- Stage 4: Stereo encode --- limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) comps := g.stereoEncoder.Encode(limited) // --- Stage 5: Composite clip + protection --- monoComponent := float64(comps.Mono) stereoComponent := 0.0 if lp.StereoEnabled { stereoComponent = float64(comps.Stereo) } audioMPX := monoComponent + stereoComponent if lp.CompositeClipperEnabled && g.compositeClip != nil { // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip audioMPX = g.compositeClip.Process(audioMPX) } else { // Legacy single-pass: one clip, then notch, no final safety clip audioMPX = dsp.HardClip(audioMPX, ceiling) audioMPX = g.mpxNotch19.Process(audioMPX) audioMPX = g.mpxNotch57.Process(audioMPX) } preAudioMpxSumSq += audioMPX * audioMPX preMonoSumSq += monoComponent * monoComponent preStereoSumSq += stereoComponent * stereoComponent if abs := math.Abs(audioMPX); abs > preAudioMpxPeak { preAudioMpxPeak = abs } // BS.412: apply gain and measure power if bs412Gain < 1.0 { audioMPX *= bs412Gain } postAudioMpxSumSq += audioMPX * audioMPX if abs := math.Abs(audioMPX); abs > postAudioMpxPeak { postAudioMpxPeak = abs } bs412PowerAccum += audioMPX * audioMPX // --- Stage 6: Add protected components --- composite := audioMPX if lp.StereoEnabled { composite += pilotAmp * comps.Pilot } rdsContribution := 0.0 if g.rdsEnc != nil && lp.RDSEnabled { rdsCarrier := g.stereoEncoder.RDSCarrier() rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier) rdsContribution = rdsAmp * rdsValue composite += rdsContribution } // RDS2: three additional subcarriers (66.5, 71.25, 76 kHz) // Each at ≤3.5% injection (ITU-R BS.450-3 limit: 10% total for all RDS) if g.rds2Enc != nil && g.rds2Enc.Enabled() { pilotPhase := g.stereoEncoder.PilotPhase() rds2Value := g.rds2Enc.NextSampleWithPilot(pilotPhase) // rds2Injection: ~3% per stream × 3 streams, split evenly composite += rdsAmp * 0.75 * rds2Value // 75% of RDS injection per stream } // Jingle: injected when unlicensed, bypasses drive/gain controls. if g.licenseState != nil && len(g.jingleFrames) > 0 { jingleContribution := g.licenseState.NextSample(g.jingleFrames) if jingleContribution != 0 { licenseInjectionActive = true } composite += jingleContribution } pilotContribution := 0.0 if lp.StereoEnabled { pilotContribution = pilotAmp * comps.Pilot } pilotSumSq += pilotContribution * pilotContribution if abs := math.Abs(pilotContribution); abs > pilotPeak { pilotPeak = abs } rdsSumSq += rdsContribution * rdsContribution if abs := math.Abs(rdsContribution); abs > rdsPeak { rdsPeak = abs } finalCompositeSumSq += composite * composite if abs := math.Abs(composite); abs > finalCompositePeak { finalCompositePeak = abs } if math.Abs(composite) > 1.0 { overNominal++ } if math.Abs(composite) > 1.1 { overHeadroom++ } 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)} } else { frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} } } preAudioRMS := safeRMS(preAudioMpxSumSq, samples) postAudioRMS := safeRMS(postAudioMpxSumSq, samples) lRMS := safeRMS(lrLSumSq, samples) rRMS := safeRMS(lrRSumSq, samples) monoRMS := safeRMS(preMonoSumSq, samples) stereoRMS := safeRMS(preStereoSumSq, samples) finalCompositeRMS := safeRMS(finalCompositeSumSq, samples) pilotRMS := safeRMS(pilotSumSq, samples) rdsRMS := safeRMS(rdsSumSq, samples) lrBalanceDB := safeDBRatio(lRMS, rRMS) clipperStats := dsp.CompositeClipperStats{} if g.compositeClip != nil { clipperStats = g.compositeClip.Stats() } measurement := &MeasurementSnapshot{ Timestamp: frame.Timestamp, SampleRateHz: g.sampleRate, ChunkSamples: samples, ChunkDurationMs: float64(samples) / g.sampleRate * 1000, Sequence: frame.Sequence, Flags: MeasurementFlags{ StereoEnabled: lp.StereoEnabled, StereoMode: g.appliedStereoMode, RDSEnabled: lp.RDSEnabled, RDS2Enabled: g.rds2Enc != nil && g.rds2Enc.Enabled(), BS412Enabled: g.bs412 != nil, CompositeClipperEnabled: lp.CompositeClipperEnabled, WatermarkEnabled: g.stftEmbedder != nil, LicenseInjectionActive: licenseInjectionActive, }, LRPreEncodePostWatermark: LRPreEncodePostWatermarkMeasurement{ LRms: lRMS, RRms: rRMS, LPeakAbs: lrLPeak, RPeakAbs: lrRPeak, LRBalanceDB: lrBalanceDB, LClipEvents: lrLClip, RClipEvents: lrRClip, }, AudioMPXPreBS412: AudioMPXPreBS412Measurement{ RMS: preAudioRMS, PeakAbs: preAudioMpxPeak, MonoRMS: monoRMS, StereoRMS: stereoRMS, CrestFactor: func() float64 { if preAudioRMS > 0 { return preAudioMpxPeak / preAudioRMS }; return 0 }(), ClipperLookaheadGain: clipperStats.LookaheadGain, ClipperEnvelope: clipperStats.Envelope, ClipperOrProtectionActive: clipperStats.LookaheadGain < 0.999 || clipperStats.Envelope > 1.0, }, AudioMPXPostBS412: AudioMPXPostBS412Measurement{ RMS: postAudioRMS, PeakAbs: postAudioMpxPeak, BS412GainApplied: bs412Gain, BS412AttenuationDB: func() float64 { if bs412Gain > 0 { return 20 * math.Log10(bs412Gain) }; return 0 }(), EstimatedAudioPower: func() float64 { if samples > 0 { return bs412PowerAccum / float64(samples) }; return 0 }(), }, CompositeFinalPreIQ: CompositeFinalPreIQMeasurement{ RMS: finalCompositeRMS, PeakAbs: finalCompositePeak, PilotRMS: pilotRMS, PilotPeakAbs: pilotPeak, PilotInjectionEquivalentPercent: clamp01(pilotPeak) * 100, RDSRMS: rdsRMS, RDSPeakAbs: rdsPeak, OverNominalEvents: overNominal, OverHeadroomEvents: overHeadroom, }, } g.latestMeasurement.Store(measurement) // BS.412: feed this chunk's actual duration and average audio power for // the next chunk's gain calculation. Using the real sample count avoids // the error that occurred when chunkSec was hardcoded to 0.05 — any // SetChunkDuration() call from the engine would silently miscalibrate // the ITU-R BS.412 power measurement window. if g.bs412 != nil && samples > 0 { chunkSec := float64(samples) / g.sampleRate g.bs412.UpdateChunkDuration(chunkSec) g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) } return frame } func (g *Generator) WriteFile(path string, duration time.Duration) error { if path == "" { path = g.cfg.Backend.OutputPath } if path == "" { path = filepath.Join("build", "offline", "composite.iqf32") } backend, err := output.NewFileBackend(path, binary.LittleEndian, output.BackendInfo{ Name: "offline-file", Description: "offline composite file backend", }) if err != nil { return err } defer backend.Close(context.Background()) if err := backend.Configure(context.Background(), output.BackendConfig{ SampleRateHz: float64(g.cfg.FM.CompositeRateHz), Channels: 2, IQLevel: float32(g.cfg.FM.OutputDrive), }); err != nil { return err } frame := g.GenerateFrame(duration) if _, err := backend.Write(context.Background(), frame); err != nil { return err } return backend.Flush(context.Background()) } func (g *Generator) Summary(duration time.Duration) string { sampleRate := float64(g.cfg.FM.CompositeRateHz) if sampleRate <= 0 { sampleRate = 228000 } _, info := g.sourceFor(sampleRate) preemph := "off" if g.cfg.FM.PreEmphasisTauUS > 0 { preemph = fmt.Sprintf("%.0fµs", g.cfg.FM.PreEmphasisTauUS) } modMode := "composite" if g.cfg.FM.FMModulationEnabled { modMode = fmt.Sprintf("FM-IQ(±%.0fHz)", g.cfg.FM.MaxDeviationHz) } return fmt.Sprintf("offline frame: freq=%.1fMHz rate=%d duration=%s drive=%.2f stereo=%t rds=%t preemph=%s limiter=%t output=%s source=%s detail=%s", g.cfg.FM.FrequencyMHz, g.cfg.FM.CompositeRateHz, duration.String(), g.cfg.FM.OutputDrive, g.cfg.FM.StereoEnabled, g.cfg.RDS.Enabled, preemph, g.cfg.FM.LimiterEnabled, modMode, info.Kind, info.Detail) }