ソースを参照

feat: add live composite/MPX measurements and UI metering

main
Jan 1ヶ月前
コミット
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,
"activePS": s.ActivePS,
"activeRadioText": s.ActiveRadioText,
"measurement": s.Measurement,
"degradedTransitions": s.DegradedTransitions,
"mutedTransitions": s.MutedTransitions,
"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`

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:**
```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.


---

### `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`


+ 29
- 27
internal/app/engine.go ファイルの表示

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

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
@@ -530,6 +531,7 @@ func (e *Engine) Stats() EngineStats {
AppliedFrequencyMHz: e.appliedFrequencyMHz(),
ActivePS: activePS,
ActiveRadioText: activeRT,
Measurement: e.generator.LatestMeasurement(),
LastFault: lastFault,
DegradedTransitions: e.degradedTransitions.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/ingest/save", s.handleIngestSave)
mux.HandleFunc("/runtime", s.handleRuntime)
mux.HandleFunc("/measurements", s.handleMeasurements)
mux.HandleFunc("/runtime/fault/reset", s.handleRuntimeFaultReset)
mux.HandleFunc("/tx/start", s.handleTXStart)
mux.HandleFunc("/tx/stop", s.handleTXStop)
@@ -355,6 +356,38 @@ func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
_ = 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) {
s.mu.RLock()
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.
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.
// Call once per sample from the DSP loop — it is not thread-safe and must
// be called from the single GenerateFrame goroutine only.


+ 245
- 4
internal/offline/generator.go ファイルの表示

@@ -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 &copy
}
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


+ 15
- 0
internal/offline/generator_test.go ファイルの表示

@@ -18,6 +18,16 @@ func TestGenerateFrame(t *testing.T) {
if frame == nil || len(frame.Samples) == 0 {
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) {
@@ -210,6 +220,11 @@ func TestConfigureWatermarkExplicitOptIn(t *testing.T) {
if g.stftEmbedder == nil {
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) {


読み込み中…
キャンセル
保存