| @@ -267,14 +267,53 @@ func extractForStreaming( | |||||
| coll *telemetry.Collector, | coll *telemetry.Collector, | ||||
| ) ([][]complex64, []int) { | ) ([][]complex64, []int) { | ||||
| if useStreamingProductionPath { | 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 | 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 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 | 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)) | out := make([][]complex64, len(signals)) | ||||
| rates := make([]int, len(signals)) | rates := make([]int, len(signals)) | ||||
| @@ -15,7 +15,6 @@ var streamingOracleRunner *gpudemod.CPUOracleRunner | |||||
| func buildStreamingJobs(sampleRate int, centerHz float64, signals []detector.Signal, aqCfg extractionConfig) ([]gpudemod.StreamingExtractJob, error) { | func buildStreamingJobs(sampleRate int, centerHz float64, signals []detector.Signal, aqCfg extractionConfig) ([]gpudemod.StreamingExtractJob, error) { | ||||
| jobs := make([]gpudemod.StreamingExtractJob, len(signals)) | jobs := make([]gpudemod.StreamingExtractJob, len(signals)) | ||||
| decimTarget := 200000 | |||||
| bwMult := aqCfg.bwMult | bwMult := aqCfg.bwMult | ||||
| if bwMult <= 0 { | if bwMult <= 0 { | ||||
| bwMult = 1.0 | bwMult = 1.0 | ||||
| @@ -29,14 +28,20 @@ func buildStreamingJobs(sampleRate int, centerHz float64, signals []detector.Sig | |||||
| sigMHz := sig.CenterHz / 1e6 | sigMHz := sig.CenterHz / 1e6 | ||||
| isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || | isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || | ||||
| (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) | ||||
| outRate := decimTarget | |||||
| var outRate int | |||||
| if isWFM { | if isWFM { | ||||
| outRate = wfmStreamOutRate | outRate = wfmStreamOutRate | ||||
| if bw < wfmStreamMinBW { | if bw < wfmStreamMinBW { | ||||
| 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 { | if _, err := gpudemod.ExactIntegerDecimation(sampleRate, outRate); err != nil { | ||||
| return nil, err | return nil, err | ||||
| @@ -92,3 +97,41 @@ func extractForStreamingOracle( | |||||
| func phaseIncForOffset(sampleRate int, offsetHz float64) float64 { | func phaseIncForOffset(sampleRate int, offsetHz float64) float64 { | ||||
| return -2.0 * math.Pi * offsetHz / float64(sampleRate) | 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 | |||||
| } | |||||
| @@ -1,6 +1,10 @@ | |||||
| package gpudemod | package gpudemod | ||||
| import "sdr-wideband-suite/internal/dsp" | |||||
| import ( | |||||
| "log" | |||||
| "sdr-wideband-suite/internal/dsp" | |||||
| ) | |||||
| func (r *BatchRunner) ResetSignalState(signalID int64) { | func (r *BatchRunner) ResetSignalState(signalID int64) { | ||||
| if r == nil || r.streamState == nil { | if r == nil || r.streamState == nil { | ||||
| @@ -35,6 +39,10 @@ func (r *BatchRunner) getOrInitExtractState(job StreamingExtractJob, sampleRate | |||||
| r.streamState[job.SignalID] = state | r.streamState[job.SignalID] = state | ||||
| } | } | ||||
| if state.ConfigHash != job.ConfigHash { | 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) | ResetExtractStreamState(state, job.ConfigHash) | ||||
| } | } | ||||
| state.Decim = decim | state.Decim = decim | ||||
| @@ -3,6 +3,7 @@ package gpudemod | |||||
| import ( | import ( | ||||
| "fmt" | "fmt" | ||||
| "hash/fnv" | "hash/fnv" | ||||
| "math" | |||||
| ) | ) | ||||
| type StreamingExtractJob struct { | 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 { | 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 := 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() | return h.Sum64() | ||||
| } | } | ||||
| @@ -61,6 +61,11 @@ type streamSession struct { | |||||
| prevExtractIQ complex64 | prevExtractIQ complex64 | ||||
| lastExtractIQSet bool | 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 | lastDemodL float32 | ||||
| prevDemodL float64 | prevDemodL float64 | ||||
| lastDemodSet bool | lastDemodSet bool | ||||
| @@ -1238,7 +1243,26 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int, col | |||||
| } | } | ||||
| // --- FM/AM/etc Demod --- | // --- 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 { | if len(audio) == 0 { | ||||
| return nil, 0 | return nil, 0 | ||||
| } | } | ||||