Browse Source

fix: stabilize streaming extraction and FM block boundaries

refactor/stateful-streaming-extractor
Jan Svabenik 8 hours ago
parent
commit
bd608fda07
5 changed files with 135 additions and 9 deletions
  1. +41
    -2
      cmd/sdrd/helpers.go
  2. +47
    -4
      cmd/sdrd/streaming_refactor.go
  3. +9
    -1
      internal/demod/gpudemod/stream_state.go
  4. +13
    -1
      internal/demod/gpudemod/streaming_types.go
  5. +25
    -1
      internal/recorder/streamer.go

+ 41
- 2
cmd/sdrd/helpers.go View File

@@ -267,14 +267,53 @@ func extractForStreaming(
coll *telemetry.Collector,
) ([][]complex64, []int) {
if useStreamingProductionPath {
if out, rates, err := extractForStreamingProduction(extractMgr, allIQ, sampleRate, centerHz, signals, aqCfg, coll); err == nil {
out, rates, err := extractForStreamingProduction(extractMgr, allIQ, sampleRate, centerHz, signals, aqCfg, coll)
if err == nil {
logging.Debug("extract", "path_active", "path", "streaming_production", "signals", len(signals), "allIQ", len(allIQ))
if coll != nil {
coll.IncCounter("extract.path.streaming_production", 1, nil)
}
return out, rates
}
// CRITICAL: the streaming production path failed — log WHY before falling through
log.Printf("EXTRACT PATH FALLTHROUGH: streaming production failed: %v — using legacy overlap+trim", err)
logging.Warn("extract", "streaming_production_fallthrough",
"err", err.Error(),
"signals", len(signals),
"allIQ", len(allIQ),
"sampleRate", sampleRate,
)
if coll != nil {
coll.IncCounter("extract.path.streaming_production_failed", 1, nil)
coll.Event("extraction_path_fallthrough", "warn",
"streaming production path failed, using legacy overlap+trim", nil,
map[string]any{
"error": err.Error(),
"signals": len(signals),
"allIQ_len": len(allIQ),
"sampleRate": sampleRate,
})
}
}
if useStreamingOraclePath {
if out, rates, err := extractForStreamingOracle(allIQ, sampleRate, centerHz, signals, aqCfg, coll); err == nil {
out, rates, err := extractForStreamingOracle(allIQ, sampleRate, centerHz, signals, aqCfg, coll)
if err == nil {
logging.Debug("extract", "path_active", "path", "streaming_oracle", "signals", len(signals))
if coll != nil {
coll.IncCounter("extract.path.streaming_oracle", 1, nil)
}
return out, rates
}
log.Printf("EXTRACT PATH FALLTHROUGH: streaming oracle failed: %v", err)
logging.Warn("extract", "streaming_oracle_fallthrough", "err", err.Error())
if coll != nil {
coll.IncCounter("extract.path.streaming_oracle_failed", 1, nil)
}
}
// If we reach here, the legacy overlap+trim path is running
logging.Warn("extract", "path_active", "path", "legacy_overlap_trim", "signals", len(signals), "allIQ", len(allIQ))
if coll != nil {
coll.IncCounter("extract.path.legacy_overlap_trim", 1, nil)
}
out := make([][]complex64, len(signals))
rates := make([]int, len(signals))


+ 47
- 4
cmd/sdrd/streaming_refactor.go View File

@@ -15,7 +15,6 @@ var streamingOracleRunner *gpudemod.CPUOracleRunner

func buildStreamingJobs(sampleRate int, centerHz float64, signals []detector.Signal, aqCfg extractionConfig) ([]gpudemod.StreamingExtractJob, error) {
jobs := make([]gpudemod.StreamingExtractJob, len(signals))
decimTarget := 200000
bwMult := aqCfg.bwMult
if bwMult <= 0 {
bwMult = 1.0
@@ -29,14 +28,20 @@ func buildStreamingJobs(sampleRate int, centerHz float64, signals []detector.Sig
sigMHz := sig.CenterHz / 1e6
isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) ||
(sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
outRate := decimTarget
var outRate int
if isWFM {
outRate = wfmStreamOutRate
if bw < wfmStreamMinBW {
bw = wfmStreamMinBW
}
} else if bw < 20000 {
bw = 20000
} else {
// Non-WFM target: must be an exact integer divisor of sampleRate.
// The old hardcoded 200000 fails for common SDR rates (e.g. 4096000/200000=20.48).
// Find the nearest valid rate >= 128000 (enough for NFM/AM/SSB).
outRate = nearestExactDecimationRate(sampleRate, 200000, 128000)
if bw < 20000 {
bw = 20000
}
}
if _, err := gpudemod.ExactIntegerDecimation(sampleRate, outRate); err != nil {
return nil, err
@@ -92,3 +97,41 @@ func extractForStreamingOracle(
func phaseIncForOffset(sampleRate int, offsetHz float64) float64 {
return -2.0 * math.Pi * offsetHz / float64(sampleRate)
}

// nearestExactDecimationRate finds the output rate closest to targetRate
// (but not below minRate) that is an exact integer divisor of sampleRate.
// This avoids the ExactIntegerDecimation check failing for rates like
// 4096000/200000=20.48 which silently killed the entire streaming batch.
func nearestExactDecimationRate(sampleRate int, targetRate int, minRate int) int {
if sampleRate <= 0 || targetRate <= 0 {
return targetRate
}
if sampleRate%targetRate == 0 {
return targetRate // already exact
}
// Try decimation factors near the target
targetDecim := sampleRate / targetRate // floor
bestRate := 0
bestDist := sampleRate // impossibly large
for d := max(1, targetDecim-2); d <= targetDecim+2; d++ {
rate := sampleRate / d
if rate < minRate {
continue
}
if sampleRate%rate != 0 {
continue // not exact (shouldn't happen since rate = sampleRate/d, but guard)
}
dist := targetRate - rate
if dist < 0 {
dist = -dist
}
if dist < bestDist {
bestDist = dist
bestRate = rate
}
}
if bestRate > 0 {
return bestRate
}
return targetRate // fallback — will fail ExactIntegerDecimation and surface the error
}

+ 9
- 1
internal/demod/gpudemod/stream_state.go View File

@@ -1,6 +1,10 @@
package gpudemod

import "sdr-wideband-suite/internal/dsp"
import (
"log"

"sdr-wideband-suite/internal/dsp"
)

func (r *BatchRunner) ResetSignalState(signalID int64) {
if r == nil || r.streamState == nil {
@@ -35,6 +39,10 @@ func (r *BatchRunner) getOrInitExtractState(job StreamingExtractJob, sampleRate
r.streamState[job.SignalID] = state
}
if state.ConfigHash != job.ConfigHash {
if state.Initialized {
log.Printf("STREAMING STATE RESET: signal=%d oldHash=%d newHash=%d historyLen=%d",
job.SignalID, state.ConfigHash, job.ConfigHash, len(state.ShiftedHistory))
}
ResetExtractStreamState(state, job.ConfigHash)
}
state.Decim = decim


+ 13
- 1
internal/demod/gpudemod/streaming_types.go View File

@@ -3,6 +3,7 @@ package gpudemod
import (
"fmt"
"hash/fnv"
"math"
)

type StreamingExtractJob struct {
@@ -48,7 +49,18 @@ func ResetExtractStreamState(state *ExtractStreamState, cfgHash uint64) {
}

func StreamingConfigHash(signalID int64, offsetHz float64, bandwidth float64, outRate int, numTaps int, sampleRate int) uint64 {
// Quantize offset and bandwidth to 1 kHz resolution before hashing.
// The detector's exponential smoothing causes CenterHz (and therefore offsetHz)
// to jitter by fractions of a Hz every frame. With %.9f formatting, this
// produced a new hash every frame → full state reset (NCOPhase=0, History=[],
// PhaseCount=0) → FIR settling + phase discontinuity → audible clicks.
//
// The NCO phase_inc is computed from the exact offset each frame, so small
// frequency changes are tracked smoothly without a reset. Only structural
// changes (bandwidth affecting FIR taps, decimation, tap count) need a reset.
qOff := math.Round(offsetHz / 1000) * 1000
qBW := math.Round(bandwidth / 1000) * 1000
h := fnv.New64a()
_, _ = h.Write([]byte(fmt.Sprintf("sig=%d|off=%.9f|bw=%.9f|out=%d|taps=%d|sr=%d", signalID, offsetHz, bandwidth, outRate, numTaps, sampleRate)))
_, _ = h.Write([]byte(fmt.Sprintf("sig=%d|off=%.0f|bw=%.0f|out=%d|taps=%d|sr=%d", signalID, qOff, qBW, outRate, numTaps, sampleRate)))
return h.Sum64()
}

+ 25
- 1
internal/recorder/streamer.go View File

@@ -61,6 +61,11 @@ type streamSession struct {
prevExtractIQ complex64
lastExtractIQSet bool

// FM discriminator cross-block bridging: carry the last IQ sample so the
// discriminator can compute the phase step across block boundaries.
lastDiscrimIQ complex64
lastDiscrimIQSet bool

lastDemodL float32
prevDemodL float64
lastDemodSet bool
@@ -1238,7 +1243,26 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int, col
}

// --- FM/AM/etc Demod ---
audio := d.Demod(dec, actualDemodRate)
// For FM demod (NFM/WFM): bridge the block boundary by prepending the
// previous block's last IQ sample. Without this, the discriminator loses
// the cross-boundary phase step (1 audio sample missing per block) and
// any phase discontinuity at the seam becomes an unsmoothed audio transient.
var audio []float32
isFMDemod := demodName == "NFM" || demodName == "WFM"
if isFMDemod && sess.lastDiscrimIQSet && len(dec) > 0 {
bridged := make([]complex64, len(dec)+1)
bridged[0] = sess.lastDiscrimIQ
copy(bridged[1:], dec)
audio = d.Demod(bridged, actualDemodRate)
// bridged produced len(dec) audio samples (= len(bridged)-1)
// which is exactly the correct count for the new data
} else {
audio = d.Demod(dec, actualDemodRate)
}
if len(dec) > 0 {
sess.lastDiscrimIQ = dec[len(dec)-1]
sess.lastDiscrimIQSet = true
}
if len(audio) == 0 {
return nil, 0
}


Loading…
Cancel
Save