|
|
|
@@ -5,6 +5,7 @@ import ( |
|
|
|
"encoding/binary" |
|
|
|
"fmt" |
|
|
|
"log" |
|
|
|
"math" |
|
|
|
"path/filepath" |
|
|
|
"sync/atomic" |
|
|
|
"time" |
|
|
|
@@ -81,6 +82,73 @@ type SourceInfo struct { |
|
|
|
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 |
|
|
|
|
|
|
|
@@ -141,6 +209,8 @@ type Generator struct { |
|
|
|
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 { |
|
|
|
@@ -205,6 +275,14 @@ 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) |
|
|
|
@@ -469,6 +547,30 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { |
|
|
|
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() |
|
|
|
|
|
|
|
@@ -495,6 +597,20 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
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 { |
|
|
|
@@ -647,16 +763,32 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
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 --- |
|
|
|
audioMPX := float64(comps.Mono) |
|
|
|
monoComponent := float64(comps.Mono) |
|
|
|
stereoComponent := 0.0 |
|
|
|
if lp.StereoEnabled { |
|
|
|
audioMPX += float64(comps.Stereo) |
|
|
|
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) |
|
|
|
@@ -667,10 +799,21 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
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 --- |
|
|
|
@@ -678,10 +821,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
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) |
|
|
|
composite += rdsAmp * rdsValue |
|
|
|
rdsContribution = rdsAmp * rdsValue |
|
|
|
composite += rdsContribution |
|
|
|
} |
|
|
|
|
|
|
|
// RDS2: three additional subcarriers (66.5, 71.25, 76 kHz) |
|
|
|
@@ -695,7 +840,33 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
|
|
|
|
// Jingle: injected when unlicensed, bypasses drive/gain controls. |
|
|
|
if g.licenseState != nil && len(g.jingleFrames) > 0 { |
|
|
|
composite += g.licenseState.NextSample(g.jingleFrames) |
|
|
|
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) |
|
|
|
@@ -705,6 +876,76 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
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 |
|
|
|
|