Procházet zdrojové kódy

fix: restore live audio session and WFM listen behavior

master
Jan Svabenik před 6 hodinami
rodič
revize
bc39e54edf
3 změnil soubory, kde provedl 271 přidání a 120 odebrání
  1. +32
    -14
      cmd/sdrd/helpers.go
  2. +99
    -85
      config.yaml
  3. +140
    -21
      internal/recorder/streamer.go

+ 32
- 14
cmd/sdrd/helpers.go Zobrazit soubor

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


+ 99
- 85
config.yaml Zobrazit soubor

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


+ 140
- 21
internal/recorder/streamer.go Zobrazit soubor

@@ -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 := "<nil>"
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 {


Načítá se…
Zrušit
Uložit