diff --git a/cmd/sdrd/helpers.go b/cmd/sdrd/helpers.go index 4a514db..4c1a63a 100644 --- a/cmd/sdrd/helpers.go +++ b/cmd/sdrd/helpers.go @@ -125,19 +125,23 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR jobs := make([]gpudemod.ExtractJob, len(signals)) for i, sig := range signals { bw := sig.BWHz + sigMHz := sig.CenterHz / 1e6 + isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) + jobOutRate := decimTarget + if isWFM { + jobOutRate = 500000 + } // Minimum extraction BW: ensure enough bandwidth for demod features // FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz // Also widen for any signal classified as WFM (in case of re-extraction) - sigMHz := sig.CenterHz / 1e6 - isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) if isWFM { - if bw < 150000 { - bw = 150000 + if bw < 250000 { + bw = 250000 } } else if bw < 20000 { bw = 20000 } - jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: decimTarget} + jobs[i] = gpudemod.ExtractJob{OffsetHz: sig.CenterHz - centerHz, BW: bw, OutRate: jobOutRate} } if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) { // batch extraction OK (silent) @@ -162,8 +166,8 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR sigMHz := sig.CenterHz / 1e6 isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) if isWFM { - if bw < 150000 { - bw = 150000 + if bw < 250000 { + bw = 250000 } } else if bw < 20000 { bw = 20000 @@ -283,7 +287,9 @@ func extractForStreaming( sigMHz := sig.CenterHz / 1e6 isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) + jobOutRate := decimTarget if isWFM { + jobOutRate = 300000 if bw < 150000 { bw = 150000 } @@ -308,7 +314,7 @@ func extractForStreaming( jobs[i] = gpudemod.ExtractJob{ OffsetHz: sig.CenterHz - centerHz, BW: bw, - OutRate: decimTarget, + OutRate: jobOutRate, PhaseStart: gpuPhaseStart, } } @@ -318,12 +324,18 @@ func extractForStreaming( if runner != nil { results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs) if err == nil && len(results) == len(signals) { - decim := sampleRate / decimTarget - if decim < 1 { - decim = 1 - } - trimSamples := overlapLen / decim for i, res := range results { + outRate := decimTarget + sigMHz := signals[i].CenterHz / 1e6 + isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (signals[i].Class != nil && (signals[i].Class.ModType == "WFM" || signals[i].Class.ModType == "WFM_STEREO")) + if isWFM { + outRate = 500000 + } + decim := sampleRate / outRate + if decim < 1 { + decim = 1 + } + trimSamples := overlapLen / decim // Update phase state — advance only by NEW data length, not overlap phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate) phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ)) @@ -377,7 +389,13 @@ func extractForStreaming( } taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps) filtered := dsp.ApplyFIR(shifted, taps) - decim := sampleRate / decimTarget + 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 + if isWFM { + outRate = 500000 + } + decim := sampleRate / outRate if decim < 1 { decim = 1 } diff --git a/config.yaml b/config.yaml index 22bbad3..d12c888 100644 --- a/config.yaml +++ b/config.yaml @@ -2,11 +2,11 @@ bands: - name: uk-fm-broadcast start_hz: 87.5e6 end_hz: 108.0e6 -center_hz: 99.5e6 -sample_rate: 2048000 +center_hz: 102.0e6 +sample_rate: 4096000 fft_size: 4096 gain_db: 32 -tuner_bw_khz: 1536 +tuner_bw_khz: 5000 use_gpu_fft: true classifier_mode: combined agc: true @@ -20,9 +20,20 @@ pipeline: monitor_start_hz: 88.0e6 monitor_end_hz: 108.0e6 monitor_span_hz: 20000000 - signal_priorities: ["wfm", "rds", "broadcast", "digital"] - auto_record_classes: ["wfm"] - auto_decode_classes: ["rds"] + monitor_windows: + - name: fm-focus + start_hz: 99.5e6 + end_hz: 104.5e6 + priority: 1.25 + zone: focus + - name: fm-rds + start_hz: 100.0e6 + end_hz: 103.5e6 + priority: 1.35 + zone: decode + signal_priorities: ["wfm", "rds", "broadcast"] + auto_record_classes: ["WFM", "WFM_STEREO"] + auto_decode_classes: ["WFM", "WFM_STEREO", "RDS"] surveillance: analysis_fft_size: 4096 frame_rate: 12 @@ -32,23 +43,29 @@ surveillance: derived_detection: auto refinement: enabled: true - max_concurrent: 16 + max_concurrent: 24 detail_fft_size: 4096 - min_candidate_snr_db: 0 - min_span_hz: 80000 + min_candidate_snr_db: -3 + min_span_hz: 60000 max_span_hz: 250000 auto_span: true resources: prefer_gpu: true - max_refinement_jobs: 16 - max_recording_streams: 8 - max_decode_jobs: 8 - decision_hold_ms: 2000 + max_refinement_jobs: 24 + max_recording_streams: 32 + max_decode_jobs: 16 + decision_hold_ms: 2500 profiles: - name: legacy description: Current single-band pipeline behavior + pipeline: + mode: legacy + profile: legacy + goals: + intent: general-monitoring surveillance: analysis_fft_size: 2048 + frame_rate: 15 strategy: single-resolution display_bins: 2048 display_fps: 15 @@ -67,13 +84,16 @@ profiles: min_span_hz: 0 max_span_hz: 0 auto_span: true - pipeline: - mode: legacy - profile: legacy - goals: - intent: general-monitoring - name: wideband-balanced description: Baseline multi-resolution wideband surveillance + pipeline: + mode: wideband-balanced + profile: wideband-balanced + goals: + intent: broadcast-monitoring + signal_priorities: ["wfm", "rds", "broadcast"] + auto_record_classes: ["WFM", "WFM_STEREO"] + auto_decode_classes: ["WFM", "WFM_STEREO", "RDS"] surveillance: analysis_fft_size: 4096 frame_rate: 12 @@ -83,26 +103,26 @@ profiles: derived_detection: auto resources: prefer_gpu: true - max_refinement_jobs: 16 - max_recording_streams: 16 - max_decode_jobs: 12 - decision_hold_ms: 2000 + max_refinement_jobs: 24 + max_recording_streams: 32 + max_decode_jobs: 16 + decision_hold_ms: 2500 refinement: enabled: true - max_concurrent: 16 + max_concurrent: 24 detail_fft_size: 4096 - min_candidate_snr_db: 0 - min_span_hz: 4000 - max_span_hz: 200000 + min_candidate_snr_db: -3 + min_span_hz: 60000 + max_span_hz: 250000 auto_span: true - pipeline: - mode: wideband-balanced - profile: wideband-balanced - goals: - intent: wideband-surveillance - signal_priorities: ["digital", "wfm"] - name: wideband-aggressive description: Higher surveillance/refinement budgets for dense wideband monitoring + pipeline: + mode: wideband-aggressive + profile: wideband-aggressive + goals: + intent: high-density-wideband-surveillance + signal_priorities: ["wfm", "rds", "broadcast", "digital"] surveillance: analysis_fft_size: 8192 frame_rate: 10 @@ -113,25 +133,25 @@ profiles: resources: prefer_gpu: true max_refinement_jobs: 32 - max_recording_streams: 24 - max_decode_jobs: 16 - decision_hold_ms: 2000 + max_recording_streams: 40 + max_decode_jobs: 24 + decision_hold_ms: 2500 refinement: enabled: true max_concurrent: 32 detail_fft_size: 8192 - min_candidate_snr_db: 0 - min_span_hz: 6000 - max_span_hz: 250000 + min_candidate_snr_db: -3 + min_span_hz: 50000 + max_span_hz: 280000 auto_span: true - pipeline: - mode: wideband-aggressive - profile: wideband-aggressive - goals: - intent: high-density-wideband-surveillance - signal_priorities: ["digital", "wfm", "trunk"] - name: archive description: Record-first monitoring profile + pipeline: + mode: archive + profile: archive + goals: + intent: archive-and-triage + signal_priorities: ["wfm", "broadcast", "digital"] surveillance: analysis_fft_size: 4096 frame_rate: 12 @@ -141,26 +161,26 @@ profiles: derived_detection: auto resources: prefer_gpu: true - max_refinement_jobs: 12 - max_recording_streams: 24 - max_decode_jobs: 12 - decision_hold_ms: 2500 + max_refinement_jobs: 16 + max_recording_streams: 40 + max_decode_jobs: 16 + decision_hold_ms: 3000 refinement: enabled: true - max_concurrent: 12 + max_concurrent: 16 detail_fft_size: 4096 - min_candidate_snr_db: 0 - min_span_hz: 4000 - max_span_hz: 200000 + min_candidate_snr_db: -2 + min_span_hz: 50000 + max_span_hz: 250000 auto_span: true - pipeline: - mode: archive - profile: archive - goals: - intent: archive-and-triage - signal_priorities: ["wfm", "nfm", "digital"] - name: digital-hunting description: Digital-first refinement and decode focus + pipeline: + mode: digital-hunting + profile: digital-hunting + goals: + intent: digital-surveillance + signal_priorities: ["rds", "digital", "wfm"] surveillance: analysis_fft_size: 4096 frame_rate: 12 @@ -170,32 +190,26 @@ profiles: derived_detection: auto resources: prefer_gpu: true - max_refinement_jobs: 16 - max_recording_streams: 12 - max_decode_jobs: 16 - decision_hold_ms: 2000 + max_refinement_jobs: 20 + max_recording_streams: 20 + max_decode_jobs: 24 + decision_hold_ms: 2500 refinement: enabled: true - max_concurrent: 16 + max_concurrent: 20 detail_fft_size: 4096 - min_candidate_snr_db: 0 - min_span_hz: 3000 - max_span_hz: 120000 + min_candidate_snr_db: -2 + min_span_hz: 50000 + max_span_hz: 200000 auto_span: true - pipeline: - mode: digital-hunting - profile: digital-hunting - goals: - intent: digital-surveillance - signal_priorities: ["ft8", "wspr", "fsk", "psk", "dmr"] detector: - threshold_db: -55 + threshold_db: -60 min_duration_ms: 120 - hold_ms: 1200 + hold_ms: 1500 ema_alpha: 0.35 hysteresis_db: 6 min_stable_frames: 3 - gap_tolerance_ms: 1200 + gap_tolerance_ms: 1500 cfar_mode: GOSCA cfar_guard_hz: 15000 cfar_train_hz: 120000 @@ -205,27 +219,27 @@ detector: cfar_scale_db: 7 cfar_wrap_around: true edge_margin_db: 4 - max_signal_bw_hz: 250000 - merge_gap_hz: 12000 + max_signal_bw_hz: 260000 + merge_gap_hz: 20000 class_history_size: 10 class_switch_ratio: 0.6 recorder: - enabled: false - min_snr_db: 10 - min_duration: 1s + enabled: true + min_snr_db: 0 + min_duration: 500ms max_duration: 300s preroll_ms: 500 record_iq: false - record_audio: false + record_audio: true auto_demod: true - auto_decode: false + auto_decode: true max_disk_mb: 0 output_dir: data/recordings - class_filter: [] - ring_seconds: 8 + class_filter: ["WFM", "WFM_STEREO"] + ring_seconds: 12 deemphasis_us: 50 extraction_fir_taps: 101 - extraction_bw_mult: 1.2 + extraction_bw_mult: 1.35 decoder: ft8_cmd: C:/WSJT/wsjtx-2.7.0-rc6/bin/jt9.exe -8 {audio} wspr_cmd: C:/WSJT/wsjtx-2.7.0-rc6/bin/wsprd.exe {audio} diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index b67e440..2a7d111 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -60,12 +60,19 @@ type streamSession struct { // Stereo decode: phase-continuous 38kHz oscillator stereoPhase float64 + // Stereo lock state for live WFM streaming + stereoEnabled bool + stereoOnCount int + stereoOffCount int // Polyphase resampler (replaces integer-decimate hack) - monoResampler *dsp.Resampler - stereoResampler *dsp.StereoResampler + monoResampler *dsp.Resampler + monoResamplerRate int + stereoResampler *dsp.StereoResampler + stereoResamplerRate int // AQ-4: Stateful FIR filters for click-free stereo decode + stereoFilterRate int stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low @@ -222,8 +229,10 @@ func (st *Streamer) FeedSnippets(items []streamFeedItem) { st.mu.Lock() recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ) hasListeners := st.hasListenersLocked() + pending := len(st.pendingListens) st.mu.Unlock() + log.Printf("LIVEAUDIO STREAM: feedSnippets items=%d recEnabled=%v hasListeners=%v pending=%d", len(items), recEnabled, hasListeners, pending) if (!recEnabled && !hasListeners) || len(items) == 0 { return } @@ -264,12 +273,36 @@ func (st *Streamer) processFeed(msg streamFeedMsg) { // Decide whether this signal needs a session needsRecording := recEnabled && sig.SNRDb >= st.policy.MinSNRDb && st.classAllowed(sig.Class) needsListen := st.signalHasListenerLocked(sig) + className := "" + demodName := "" + if sig.Class != nil { + className = string(sig.Class.ModType) + demodName, _ = resolveDemod(sig) + } + log.Printf("LIVEAUDIO STREAM: signal id=%d center=%.3fMHz bw=%.0f snr=%.1f class=%s demod=%s needsRecord=%v needsListen=%v", sig.ID, sig.CenterHz/1e6, sig.BWHz, sig.SNRDb, className, demodName, needsRecording, needsListen) if !needsRecording && !needsListen { continue } sess, exists := st.sessions[sig.ID] + requestedMode := "" + for _, pl := range st.pendingListens { + if math.Abs(sig.CenterHz-pl.freq) < 200000 { + if m := normalizeRequestedMode(pl.mode); m != "" { + requestedMode = m + break + } + } + } + if exists && sess.listenOnly && requestedMode != "" && sess.demodName != requestedMode { + for _, sub := range sess.audioSubs { + st.pendingListens[sub.id] = &pendingListen{freq: sig.CenterHz, bw: sig.BWHz, mode: requestedMode, ch: sub.ch} + } + delete(st.sessions, sig.ID) + sess = nil + exists = false + } if !exists { if needsRecording { s, err := st.openRecordingSession(sig, now) @@ -370,10 +403,13 @@ func (st *Streamer) processFeed(msg streamFeedMsg) { func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool { if sess, ok := st.sessions[sig.ID]; ok && len(sess.audioSubs) > 0 { + log.Printf("LIVEAUDIO MATCH: signal id=%d matched existing session listener center=%.3fMHz", sig.ID, sig.CenterHz/1e6) return true } - for _, pl := range st.pendingListens { - if math.Abs(sig.CenterHz-pl.freq) < 200000 { + for subID, pl := range st.pendingListens { + delta := math.Abs(sig.CenterHz - pl.freq) + if delta < 200000 { + log.Printf("LIVEAUDIO MATCH: signal id=%d matched pending subscriber=%d center=%.3fMHz req=%.3fMHz delta=%.0fHz", sig.ID, subID, sig.CenterHz/1e6, pl.freq/1e6, delta) return true } } @@ -382,6 +418,10 @@ func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool { func (st *Streamer) attachPendingListeners(sess *streamSession) { for subID, pl := range st.pendingListens { + requestedMode := normalizeRequestedMode(pl.mode) + if requestedMode != "" && sess.demodName != requestedMode { + continue + } if math.Abs(sess.centerHz-pl.freq) < 200000 { sess.audioSubs = append(sess.audioSubs, audioSub{id: subID, ch: pl.ch}) delete(st.pendingListens, subID) @@ -453,10 +493,15 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 st.nextSub++ subID := st.nextSub + requestedMode := normalizeRequestedMode(mode) + // Try to find a matching session var bestSess *streamSession bestDist := math.MaxFloat64 for _, sess := range st.sessions { + if requestedMode != "" && sess.demodName != requestedMode { + continue + } d := math.Abs(sess.centerHz - freq) if d < bestDist { bestDist = d @@ -491,6 +536,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64 DemodName: "NFM", } log.Printf("STREAM: subscriber %d pending (freq=%.1fMHz)", subID, freq/1e6) + log.Printf("LIVEAUDIO MATCH: subscriber=%d pending req=%.3fMHz bw=%.0f mode=%s", subID, freq/1e6, bw, mode) return subID, ch, info, nil } @@ -606,23 +652,48 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] } } - // --- Stateful stereo decode --- + // --- Stateful stereo decode with conservative lock/hysteresis --- channels := 1 if isWFMStereo { - channels = 2 - audio = sess.stereoDecodeStateful(audio, actualDemodRate) + channels = 2 // keep transport format stable for live WFM_STEREO sessions + stereoAudio, locked := sess.stereoDecodeStateful(audio, actualDemodRate) + if locked { + sess.stereoOnCount++ + sess.stereoOffCount = 0 + if sess.stereoOnCount >= 4 { + sess.stereoEnabled = true + } + } else { + sess.stereoOnCount = 0 + sess.stereoOffCount++ + if sess.stereoOffCount >= 10 { + sess.stereoEnabled = false + } + } + if sess.stereoEnabled && len(stereoAudio) > 0 { + audio = stereoAudio + } else { + dual := make([]float32, len(audio)*2) + for i, s := range audio { + dual[i*2] = s + dual[i*2+1] = s + } + audio = dual + } } // --- Polyphase resample to exact 48kHz --- if actualDemodRate != streamAudioRate { if channels > 1 { - if sess.stereoResampler == nil { + if sess.stereoResampler == nil || sess.stereoResamplerRate != actualDemodRate { sess.stereoResampler = dsp.NewStereoResampler(actualDemodRate, streamAudioRate, resamplerTaps) + sess.stereoResamplerRate = actualDemodRate } audio = sess.stereoResampler.Process(audio) } else { - if sess.monoResampler == nil { + if sess.monoResampler == nil || sess.monoResamplerRate != actualDemodRate { sess.monoResampler = dsp.NewResampler(actualDemodRate, streamAudioRate, resamplerTaps) + sess.monoResamplerRate = actualDemodRate } audio = sess.monoResampler.Process(audio) } @@ -652,25 +723,32 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] } } + if isWFM { + for i := range audio { + audio[i] *= 0.35 + } + } + return audio, streamAudioRate } // stereoDecodeStateful: phase-continuous 38kHz oscillator for L-R extraction. // AQ-4: Uses persistent FIR filter state across frames for click-free stereo. // Reuses session scratch buffers to minimize allocations. -func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) []float32 { +func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) { if len(mono) == 0 || sampleRate <= 0 { - return nil + return nil, false } n := len(mono) - // Lazy-init stateful filters on first call - if sess.stereoLPF == nil { + // Rebuild rate-dependent stereo filters when sampleRate changes + if sess.stereoLPF == nil || sess.stereoFilterRate != sampleRate { lp := dsp.LowpassFIR(15000, sampleRate, 101) sess.stereoLPF = dsp.NewStatefulFIRReal(lp) sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101)) sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101)) sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp) + sess.stereoFilterRate = sampleRate } // Reuse scratch for intermediates: need 4*n float32 for bpf, lr, hi, lo @@ -692,20 +770,26 @@ func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) phase := sess.stereoPhase inc := 2 * math.Pi * 38000 / float64(sampleRate) + var pilotPower float64 + var totalPower float64 for i := range bpf { phase += inc - lr[i] = bpf[i] * float32(2*math.Cos(phase)) + v := bpf[i] * float32(2*math.Cos(phase)) + lr[i] = v + pilotPower += math.Abs(float64(bpf[i])) + totalPower += math.Abs(float64(mono[i])) } sess.stereoPhase = math.Mod(phase, 2*math.Pi) lr = sess.stereoLRLPF.Process(lr) + locked := totalPower > 0 && (pilotPower/totalPower) > 0.12 out := make([]float32, n*2) for i := 0; i < n; i++ { out[i*2] = 0.5 * (lpr[i] + lr[i]) out[i*2+1] = 0.5 * (lpr[i] - lr[i]) } - return out + return out, locked } // dspStateSnapshot captures persistent DSP state for segment splits. @@ -714,9 +798,12 @@ type dspStateSnapshot struct { deemphL float64 deemphR float64 stereoPhase float64 - monoResampler *dsp.Resampler - stereoResampler *dsp.StereoResampler - stereoLPF *dsp.StatefulFIRReal + monoResampler *dsp.Resampler + monoResamplerRate int + stereoResampler *dsp.StereoResampler + stereoResamplerRate int + stereoLPF *dsp.StatefulFIRReal + stereoFilterRate int stereoBPHi *dsp.StatefulFIRReal stereoBPLo *dsp.StatefulFIRReal stereoLRLPF *dsp.StatefulFIRReal @@ -733,9 +820,12 @@ func (sess *streamSession) captureDSPState() dspStateSnapshot { deemphL: sess.deemphL, deemphR: sess.deemphR, stereoPhase: sess.stereoPhase, - monoResampler: sess.monoResampler, - stereoResampler: sess.stereoResampler, - stereoLPF: sess.stereoLPF, + monoResampler: sess.monoResampler, + monoResamplerRate: sess.monoResamplerRate, + stereoResampler: sess.stereoResampler, + stereoResamplerRate: sess.stereoResamplerRate, + stereoLPF: sess.stereoLPF, + stereoFilterRate: sess.stereoFilterRate, stereoBPHi: sess.stereoBPHi, stereoBPLo: sess.stereoBPLo, stereoLRLPF: sess.stereoLRLPF, @@ -753,8 +843,11 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { sess.deemphR = s.deemphR sess.stereoPhase = s.stereoPhase sess.monoResampler = s.monoResampler + sess.monoResamplerRate = s.monoResamplerRate sess.stereoResampler = s.stereoResampler + sess.stereoResamplerRate = s.stereoResamplerRate sess.stereoLPF = s.stereoLPF + sess.stereoFilterRate = s.stereoFilterRate sess.stereoBPHi = s.stereoBPHi sess.stereoBPLo = s.stereoBPLo sess.stereoLRLPF = s.stereoLRLPF @@ -819,6 +912,21 @@ func (st *Streamer) openRecordingSession(sig *detector.Signal, now time.Time) (* func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *streamSession { demodName, channels := resolveDemod(sig) + for _, pl := range st.pendingListens { + if math.Abs(sig.CenterHz-pl.freq) < 200000 { + if requested := normalizeRequestedMode(pl.mode); requested != "" { + demodName = requested + if demodName == "WFM_STEREO" { + channels = 2 + } else if d := demod.Get(demodName); d != nil { + channels = d.Channels() + } else { + channels = 1 + } + break + } + } + } sess := &streamSession{ signalID: sig.ID, @@ -857,6 +965,17 @@ func resolveDemod(sig *detector.Signal) (string, int) { return demodName, channels } +func normalizeRequestedMode(mode string) string { + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "", "AUTO": + return "" + case "WFM", "WFM_STEREO", "NFM", "AM", "USB", "LSB", "CW": + return strings.ToUpper(strings.TrimSpace(mode)) + default: + return "" + } +} + // growIQ returns a complex64 slice of at least n elements, reusing sess.scratchIQ. func (sess *streamSession) growIQ(n int) []complex64 { if cap(sess.scratchIQ) >= n {