Просмотр исходного кода

feat: add live composite/MPX measurements and UI metering

main
Jan 1 месяц назад
Родитель
Сommit
3fee92ca8f
8 измененных файлов: 469 добавлений и 50 удалений
  1. +1
    -0
      cmd/fmrtx/main.go
  2. +84
    -1
      docs/API.md
  3. +29
    -27
      internal/app/engine.go
  4. +33
    -0
      internal/control/control.go
  5. +59
    -18
      internal/control/ui.html
  6. +3
    -0
      internal/license/license.go
  7. +245
    -4
      internal/offline/generator.go
  8. +15
    -0
      internal/offline/generator_test.go

+ 1
- 0
cmd/fmrtx/main.go Просмотреть файл

@@ -351,6 +351,7 @@ func (b *txBridge) TXStats() map[string]any {
"appliedFrequencyMHz": s.AppliedFrequencyMHz, "appliedFrequencyMHz": s.AppliedFrequencyMHz,
"activePS": s.ActivePS, "activePS": s.ActivePS,
"activeRadioText": s.ActiveRadioText, "activeRadioText": s.ActiveRadioText,
"measurement": s.Measurement,
"degradedTransitions": s.DegradedTransitions, "degradedTransitions": s.DegradedTransitions,
"mutedTransitions": s.MutedTransitions, "mutedTransitions": s.MutedTransitions,
"faultedTransitions": s.FaultedTransitions, "faultedTransitions": s.FaultedTransitions,


+ 84
- 1
docs/API.md Просмотреть файл

@@ -57,7 +57,7 @@ Current transmitter status (read-only snapshot). Runtime indicator, alert, and q


### `GET /runtime` ### `GET /runtime`


Live engine and driver telemetry. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under `ingest`.
Live engine and driver telemetry. When ingest runtime is configured, this endpoint also exposes shared ingest/source stats under `ingest`. The engine payload may also include the latest measurement snapshot under `engine.measurement` for convenience, but high-frequency metering clients should prefer `/measurements`.


**Response:** **Response:**
```json ```json
@@ -152,6 +152,89 @@ Live engine and driver telemetry. When ingest runtime is configured, this endpoi
`controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload. `controlAudit` mirrors the control plane's HTTP reject counters (405/415/413/400). Whenever the HTTP server rejects a request (method not allowed, unsupported media type, body too large, or unexpected body), the respective counter increments — this lets runtime telemetry spot abusive clients without polluting the runtime state payload.




---

### `GET /measurements`

High-frequency live metering snapshot for the multiplex chain. This endpoint is intended for Overview/Flow signal metering and should be preferred over polling `/runtime` at high rate.

**Response when no current snapshot is available:**
```json
{
"noData": true,
"stale": true
}
```

**Response when a snapshot is available:**
```json
{
"noData": false,
"stale": false,
"measurement": {
"timestamp": "2026-04-13T05:30:00Z",
"sampleRateHz": 228000,
"chunkSamples": 11400,
"chunkDurationMs": 50,
"sequence": 12345,
"flags": {
"stereoEnabled": true,
"stereoMode": "DSB",
"rdsEnabled": true,
"rds2Enabled": false,
"bs412Enabled": true,
"compositeClipperEnabled": true,
"watermarkEnabled": false,
"licenseInjectionActive": false
},
"lrPreEncodePostWatermark": {
"lRms": 0.41,
"rRms": 0.39,
"lPeakAbs": 0.98,
"rPeakAbs": 0.96,
"lrBalanceDb": 0.42,
"lClipEvents": 12,
"rClipEvents": 8
},
"audioMpxPreBs412": {
"rms": 0.52,
"peakAbs": 1.0,
"monoRms": 0.34,
"stereoRms": 0.18,
"crestFactor": 1.92,
"clipperLookaheadGain": 0.94,
"clipperEnvelope": 1.03,
"clipperOrProtectionActive": true
},
"audioMpxPostBs412": {
"rms": 0.46,
"peakAbs": 0.91,
"bs412GainApplied": 0.88,
"bs412AttenuationDb": -1.11,
"estimatedAudioPower": 0.21
},
"compositeFinalPreIq": {
"rms": 0.49,
"peakAbs": 1.08,
"pilotRms": 0.064,
"pilotPeakAbs": 0.09,
"pilotInjectionEquivalentPercent": 9.0,
"rdsRms": 0.028,
"rdsPeakAbs": 0.04,
"overNominalEvents": 91,
"overHeadroomEvents": 0
}
}
}
```

Notes:
- `pilotInjectionEquivalentPercent` is an operator-facing derived value and is separate from the raw `pilotRms` field.
- `rdsInjectionEquivalentPercent` is intentionally not exposed yet in MVP until its derivation is mathematically fixed.
- `clipperLookaheadGain` and `clipperEnvelope` are preferred raw diagnostics; `clipperOrProtectionActive` is only a derived convenience indicator.
- `licenseInjectionActive` means chunk-local actual activity, not merely feature presence.
- `overNominalEvents` / `overHeadroomEvents` are internal normalized composite envelope counters, not legal overmodulation verdicts.

--- ---


### `POST /runtime/fault/reset` ### `POST /runtime/fault/reset`


+ 29
- 27
internal/app/engine.go Просмотреть файл

@@ -71,33 +71,34 @@ func durationMs(ns uint64) float64 {
} }


type EngineStats struct { type EngineStats struct {
State string `json:"state"`
RuntimeStateDurationSeconds float64 `json:"runtimeStateDurationSeconds"`
ChunksProduced uint64 `json:"chunksProduced"`
TotalSamples uint64 `json:"totalSamples"`
Underruns uint64 `json:"underruns"`
LateBuffers uint64 `json:"lateBuffers,omitempty"`
LastError string `json:"lastError,omitempty"`
UptimeSeconds float64 `json:"uptimeSeconds"`
MaxCycleMs float64 `json:"maxCycleMs,omitempty"`
MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"`
MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"`
MaxWriteMs float64 `json:"maxWriteMs,omitempty"`
MaxQueueResidenceMs float64 `json:"maxQueueResidenceMs,omitempty"`
MaxPipelineLatencyMs float64 `json:"maxPipelineLatencyMs,omitempty"`
Queue output.QueueStats `json:"queue"`
RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"`
RuntimeAlert string `json:"runtimeAlert,omitempty"`
AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"`
ActivePS string `json:"activePS,omitempty"`
ActiveRadioText string `json:"activeRadioText,omitempty"`
LastFault *FaultEvent `json:"lastFault,omitempty"`
DegradedTransitions uint64 `json:"degradedTransitions"`
MutedTransitions uint64 `json:"mutedTransitions"`
FaultedTransitions uint64 `json:"faultedTransitions"`
FaultCount uint64 `json:"faultCount"`
FaultHistory []FaultEvent `json:"faultHistory,omitempty"`
TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"`
State string `json:"state"`
RuntimeStateDurationSeconds float64 `json:"runtimeStateDurationSeconds"`
ChunksProduced uint64 `json:"chunksProduced"`
TotalSamples uint64 `json:"totalSamples"`
Underruns uint64 `json:"underruns"`
LateBuffers uint64 `json:"lateBuffers,omitempty"`
LastError string `json:"lastError,omitempty"`
UptimeSeconds float64 `json:"uptimeSeconds"`
MaxCycleMs float64 `json:"maxCycleMs,omitempty"`
MaxGenerateMs float64 `json:"maxGenerateMs,omitempty"`
MaxUpsampleMs float64 `json:"maxUpsampleMs,omitempty"`
MaxWriteMs float64 `json:"maxWriteMs,omitempty"`
MaxQueueResidenceMs float64 `json:"maxQueueResidenceMs,omitempty"`
MaxPipelineLatencyMs float64 `json:"maxPipelineLatencyMs,omitempty"`
Queue output.QueueStats `json:"queue"`
RuntimeIndicator RuntimeIndicator `json:"runtimeIndicator"`
RuntimeAlert string `json:"runtimeAlert,omitempty"`
AppliedFrequencyMHz float64 `json:"appliedFrequencyMHz"`
ActivePS string `json:"activePS,omitempty"`
ActiveRadioText string `json:"activeRadioText,omitempty"`
Measurement *offpkg.MeasurementSnapshot `json:"measurement,omitempty"`
LastFault *FaultEvent `json:"lastFault,omitempty"`
DegradedTransitions uint64 `json:"degradedTransitions"`
MutedTransitions uint64 `json:"mutedTransitions"`
FaultedTransitions uint64 `json:"faultedTransitions"`
FaultCount uint64 `json:"faultCount"`
FaultHistory []FaultEvent `json:"faultHistory,omitempty"`
TransitionHistory []RuntimeTransition `json:"transitionHistory,omitempty"`
} }


type RuntimeIndicator string type RuntimeIndicator string
@@ -530,6 +531,7 @@ func (e *Engine) Stats() EngineStats {
AppliedFrequencyMHz: e.appliedFrequencyMHz(), AppliedFrequencyMHz: e.appliedFrequencyMHz(),
ActivePS: activePS, ActivePS: activePS,
ActiveRadioText: activeRT, ActiveRadioText: activeRT,
Measurement: e.generator.LatestMeasurement(),
LastFault: lastFault, LastFault: lastFault,
DegradedTransitions: e.degradedTransitions.Load(), DegradedTransitions: e.degradedTransitions.Load(),
MutedTransitions: e.mutedTransitions.Load(), MutedTransitions: e.mutedTransitions.Load(),


+ 33
- 0
internal/control/control.go Просмотреть файл

@@ -289,6 +289,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/config", s.handleConfig) mux.HandleFunc("/config", s.handleConfig)
mux.HandleFunc("/config/ingest/save", s.handleIngestSave) mux.HandleFunc("/config/ingest/save", s.handleIngestSave)
mux.HandleFunc("/runtime", s.handleRuntime) mux.HandleFunc("/runtime", s.handleRuntime)
mux.HandleFunc("/measurements", s.handleMeasurements)
mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset) mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
mux.HandleFunc("/tx/start", s.handleTXStart) mux.HandleFunc("/tx/start", s.handleTXStart)
mux.HandleFunc("/tx/stop", s.handleTXStop) mux.HandleFunc("/tx/stop", s.handleTXStop)
@@ -355,6 +356,38 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(status) _ = json.NewEncoder(w).Encode(status)
} }


func (s *Server) handleMeasurements(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
tx := s.tx
s.mu.RUnlock()

result := map[string]any{"noData": true, "stale": true}
if tx != nil {
if stats := tx.TXStats(); stats != nil {
if measurement, ok := stats["measurement"]; ok && measurement != nil {
result = map[string]any{"noData": false, "stale": false, "measurement": measurement}
if state, ok := stats["state"]; ok {
result["state"] = state
}
if applied, ok := stats["appliedFrequencyMHz"]; ok {
result["appliedFrequencyMHz"] = applied
}
if queue, ok := stats["queue"]; ok {
result["queue"] = queue
}
if runtimeIndicator, ok := stats["runtimeIndicator"]; ok {
result["runtimeIndicator"] = runtimeIndicator
}
if runtimeAlert, ok := stats["runtimeAlert"]; ok {
result["runtimeAlert"] = runtimeAlert
}
}
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}

func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock() s.mu.RLock()
drv := s.drv drv := s.drv


+ 59
- 18
internal/control/ui.html
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 3
- 0
internal/license/license.go Просмотреть файл

@@ -59,6 +59,9 @@ func NewState(key string) *State {
// Licensed reports whether a valid key was supplied. // Licensed reports whether a valid key was supplied.
func (s *State) Licensed() bool { return s.licensed } func (s *State) Licensed() bool { return s.licensed }


// Active reports whether the jingle is currently playing.
func (s *State) Active() bool { return s.active }

// NextSample returns the jingle contribution for one composite sample. // NextSample returns the jingle contribution for one composite sample.
// Call once per sample from the DSP loop — it is not thread-safe and must // Call once per sample from the DSP loop — it is not thread-safe and must
// be called from the single GenerateFrame goroutine only. // be called from the single GenerateFrame goroutine only.


+ 245
- 4
internal/offline/generator.go Просмотреть файл

@@ -5,6 +5,7 @@ import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"log" "log"
"math"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -81,6 +82,73 @@ type SourceInfo struct {
Detail string 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 { type Generator struct {
cfg cfgpkg.Config cfg cfgpkg.Config


@@ -141,6 +209,8 @@ type Generator struct {
stftEmbedder *watermark.STFTEmbedder stftEmbedder *watermark.STFTEmbedder
wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation
wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample

latestMeasurement atomic.Pointer[MeasurementSnapshot]
} }


func NewGenerator(cfg cfgpkg.Config) *Generator { func NewGenerator(cfg cfgpkg.Config) *Generator {
@@ -205,6 +275,14 @@ func (g *Generator) RDSEncoder() *rds.Encoder {
return g.rdsEnc return g.rdsEnc
} }


func (g *Generator) LatestMeasurement() *MeasurementSnapshot {
if m := g.latestMeasurement.Load(); m != nil {
copy := *m
return &copy
}
return nil
}

func (g *Generator) resetSource() { func (g *Generator) resetSource() {
rawSource, _ := g.sourceFor(g.sampleRate) rawSource, _ := g.sourceFor(g.sampleRate)
g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) 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"} 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 { func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame {
g.init() g.init()


@@ -495,6 +597,20 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
lBuf := make([]float64, samples) lBuf := make([]float64, samples)
rBuf := 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 // Load live params once per chunk — single atomic read, zero per-sample cost
lp := g.liveParams.Load() lp := g.liveParams.Load()
if lp == nil { if lp == nil {
@@ -647,16 +763,32 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
for i := 0; i < samples; i++ { for i := 0; i < samples; i++ {
l := lBuf[i] l := lBuf[i]
r := rBuf[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 --- // --- Stage 4: Stereo encode ---
limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) limited := audio.NewFrame(audio.Sample(l), audio.Sample(r))
comps := g.stereoEncoder.Encode(limited) comps := g.stereoEncoder.Encode(limited)


// --- Stage 5: Composite clip + protection --- // --- Stage 5: Composite clip + protection ---
audioMPX := float64(comps.Mono)
monoComponent := float64(comps.Mono)
stereoComponent := 0.0
if lp.StereoEnabled { if lp.StereoEnabled {
audioMPX += float64(comps.Stereo)
stereoComponent = float64(comps.Stereo)
} }
audioMPX := monoComponent + stereoComponent
if lp.CompositeClipperEnabled && g.compositeClip != nil { if lp.CompositeClipperEnabled && g.compositeClip != nil {
// ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip // ITU-R SM.1268 iterative clipper: look-ahead + N×(clip→notch→notch) + final clip
audioMPX = g.compositeClip.Process(audioMPX) audioMPX = g.compositeClip.Process(audioMPX)
@@ -667,10 +799,21 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
audioMPX = g.mpxNotch57.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 // BS.412: apply gain and measure power
if bs412Gain < 1.0 { if bs412Gain < 1.0 {
audioMPX *= bs412Gain audioMPX *= bs412Gain
} }
postAudioMpxSumSq += audioMPX * audioMPX
if abs := math.Abs(audioMPX); abs > postAudioMpxPeak {
postAudioMpxPeak = abs
}
bs412PowerAccum += audioMPX * audioMPX bs412PowerAccum += audioMPX * audioMPX


// --- Stage 6: Add protected components --- // --- Stage 6: Add protected components ---
@@ -678,10 +821,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
if lp.StereoEnabled { if lp.StereoEnabled {
composite += pilotAmp * comps.Pilot composite += pilotAmp * comps.Pilot
} }
rdsContribution := 0.0
if g.rdsEnc != nil && lp.RDSEnabled { if g.rdsEnc != nil && lp.RDSEnabled {
rdsCarrier := g.stereoEncoder.RDSCarrier() rdsCarrier := g.stereoEncoder.RDSCarrier()
rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier) rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier)
composite += rdsAmp * rdsValue
rdsContribution = rdsAmp * rdsValue
composite += rdsContribution
} }


// RDS2: three additional subcarriers (66.5, 71.25, 76 kHz) // 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. // Jingle: injected when unlicensed, bypasses drive/gain controls.
if g.licenseState != nil && len(g.jingleFrames) > 0 { 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 { if g.fmMod != nil {
iq_i, iq_q := g.fmMod.Modulate(composite) 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 // 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 next chunk's gain calculation. Using the real sample count avoids
// the error that occurred when chunkSec was hardcoded to 0.05 — any // the error that occurred when chunkSec was hardcoded to 0.05 — any


+ 15
- 0
internal/offline/generator_test.go Просмотреть файл

@@ -18,6 +18,16 @@ func TestGenerateFrame(t *testing.T) {
if frame == nil || len(frame.Samples) == 0 { if frame == nil || len(frame.Samples) == 0 {
t.Fatal("expected samples") t.Fatal("expected samples")
} }
m := g.LatestMeasurement()
if m == nil {
t.Fatal("expected latest measurement")
}
if m.ChunkSamples == 0 || m.SampleRateHz <= 0 {
t.Fatal("expected measurement metadata")
}
if m.Flags.StereoMode == "" {
t.Fatal("expected stereo mode flag")
}
} }


func TestGenerateFrameFMIQ(t *testing.T) { func TestGenerateFrameFMIQ(t *testing.T) {
@@ -210,6 +220,11 @@ func TestConfigureWatermarkExplicitOptIn(t *testing.T) {
if g.stftEmbedder == nil { if g.stftEmbedder == nil {
t.Fatal("expected watermark embedder after explicit opt-in") t.Fatal("expected watermark embedder after explicit opt-in")
} }
_ = g.GenerateFrame(10 * time.Millisecond)
m := g.LatestMeasurement()
if m == nil || !m.Flags.WatermarkEnabled {
t.Fatal("expected watermark flag in measurement")
}
} }


func TestGeneratorResetRestoresDeterministicFirstFrame(t *testing.T) { func TestGeneratorResetRestoresDeterministicFirstFrame(t *testing.T) {


Загрузка…
Отмена
Сохранить