소스 검색

fix: restore live audio session and WFM listen behavior

master
Jan Svabenik 8 시간 전
부모
커밋
bc39e54edf
3개의 변경된 파일271개의 추가작업 그리고 120개의 파일을 삭제
  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 파일 보기

@@ -125,19 +125,23 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR
jobs := make([]gpudemod.ExtractJob, len(signals)) jobs := make([]gpudemod.ExtractJob, len(signals))
for i, sig := range signals { for i, sig := range signals {
bw := sig.BWHz 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 // Minimum extraction BW: ensure enough bandwidth for demod features
// FM broadcast (87.5-108 MHz) needs >=150kHz for stereo pilot + RDS at 57kHz // 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) // 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 isWFM {
if bw < 150000 {
bw = 150000
if bw < 250000 {
bw = 250000
} }
} else if bw < 20000 { } else if bw < 20000 {
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) { if gpuOuts, gpuRates, err := runner.ShiftFilterDecimateBatch(iq, jobs); err == nil && len(gpuOuts) == len(signals) {
// batch extraction OK (silent) // batch extraction OK (silent)
@@ -162,8 +166,8 @@ func extractSignalIQBatch(extractMgr *extractionManager, iq []complex64, sampleR
sigMHz := sig.CenterHz / 1e6 sigMHz := sig.CenterHz / 1e6
isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO")) isWFM := (sigMHz >= 87.5 && sigMHz <= 108.0) || (sig.Class != nil && (sig.Class.ModType == "WFM" || sig.Class.ModType == "WFM_STEREO"))
if isWFM { if isWFM {
if bw < 150000 {
bw = 150000
if bw < 250000 {
bw = 250000
} }
} else if bw < 20000 { } else if bw < 20000 {
bw = 20000 bw = 20000
@@ -283,7 +287,9 @@ func extractForStreaming(
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"))
jobOutRate := decimTarget
if isWFM { if isWFM {
jobOutRate = 300000
if bw < 150000 { if bw < 150000 {
bw = 150000 bw = 150000
} }
@@ -308,7 +314,7 @@ func extractForStreaming(
jobs[i] = gpudemod.ExtractJob{ jobs[i] = gpudemod.ExtractJob{
OffsetHz: sig.CenterHz - centerHz, OffsetHz: sig.CenterHz - centerHz,
BW: bw, BW: bw,
OutRate: decimTarget,
OutRate: jobOutRate,
PhaseStart: gpuPhaseStart, PhaseStart: gpuPhaseStart,
} }
} }
@@ -318,12 +324,18 @@ func extractForStreaming(
if runner != nil { if runner != nil {
results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs) results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
if err == nil && len(results) == len(signals) { if err == nil && len(results) == len(signals) {
decim := sampleRate / decimTarget
if decim < 1 {
decim = 1
}
trimSamples := overlapLen / decim
for i, res := range results { 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 // Update phase state — advance only by NEW data length, not overlap
phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate) phaseInc := -2.0 * math.Pi * jobs[i].OffsetHz / float64(sampleRate)
phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ)) phaseState[signals[i].ID].phase += phaseInc * float64(len(allIQ))
@@ -377,7 +389,13 @@ func extractForStreaming(
} }
taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps) taps := dsp.LowpassFIR(cutoff, sampleRate, firTaps)
filtered := dsp.ApplyFIR(shifted, taps) 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 { if decim < 1 {
decim = 1 decim = 1
} }


+ 99
- 85
config.yaml 파일 보기

@@ -2,11 +2,11 @@ bands:
- name: uk-fm-broadcast - name: uk-fm-broadcast
start_hz: 87.5e6 start_hz: 87.5e6
end_hz: 108.0e6 end_hz: 108.0e6
center_hz: 99.5e6
sample_rate: 2048000
center_hz: 102.0e6
sample_rate: 4096000
fft_size: 4096 fft_size: 4096
gain_db: 32 gain_db: 32
tuner_bw_khz: 1536
tuner_bw_khz: 5000
use_gpu_fft: true use_gpu_fft: true
classifier_mode: combined classifier_mode: combined
agc: true agc: true
@@ -20,9 +20,20 @@ pipeline:
monitor_start_hz: 88.0e6 monitor_start_hz: 88.0e6
monitor_end_hz: 108.0e6 monitor_end_hz: 108.0e6
monitor_span_hz: 20000000 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: surveillance:
analysis_fft_size: 4096 analysis_fft_size: 4096
frame_rate: 12 frame_rate: 12
@@ -32,23 +43,29 @@ surveillance:
derived_detection: auto derived_detection: auto
refinement: refinement:
enabled: true enabled: true
max_concurrent: 16
max_concurrent: 24
detail_fft_size: 4096 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 max_span_hz: 250000
auto_span: true auto_span: true
resources: resources:
prefer_gpu: true 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: profiles:
- name: legacy - name: legacy
description: Current single-band pipeline behavior description: Current single-band pipeline behavior
pipeline:
mode: legacy
profile: legacy
goals:
intent: general-monitoring
surveillance: surveillance:
analysis_fft_size: 2048 analysis_fft_size: 2048
frame_rate: 15
strategy: single-resolution strategy: single-resolution
display_bins: 2048 display_bins: 2048
display_fps: 15 display_fps: 15
@@ -67,13 +84,16 @@ profiles:
min_span_hz: 0 min_span_hz: 0
max_span_hz: 0 max_span_hz: 0
auto_span: true auto_span: true
pipeline:
mode: legacy
profile: legacy
goals:
intent: general-monitoring
- name: wideband-balanced - name: wideband-balanced
description: Baseline multi-resolution wideband surveillance 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: surveillance:
analysis_fft_size: 4096 analysis_fft_size: 4096
frame_rate: 12 frame_rate: 12
@@ -83,26 +103,26 @@ profiles:
derived_detection: auto derived_detection: auto
resources: resources:
prefer_gpu: true 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: refinement:
enabled: true enabled: true
max_concurrent: 16
max_concurrent: 24
detail_fft_size: 4096 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 auto_span: true
pipeline:
mode: wideband-balanced
profile: wideband-balanced
goals:
intent: wideband-surveillance
signal_priorities: ["digital", "wfm"]
- name: wideband-aggressive - name: wideband-aggressive
description: Higher surveillance/refinement budgets for dense wideband monitoring 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: surveillance:
analysis_fft_size: 8192 analysis_fft_size: 8192
frame_rate: 10 frame_rate: 10
@@ -113,25 +133,25 @@ profiles:
resources: resources:
prefer_gpu: true prefer_gpu: true
max_refinement_jobs: 32 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: refinement:
enabled: true enabled: true
max_concurrent: 32 max_concurrent: 32
detail_fft_size: 8192 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 auto_span: true
pipeline:
mode: wideband-aggressive
profile: wideband-aggressive
goals:
intent: high-density-wideband-surveillance
signal_priorities: ["digital", "wfm", "trunk"]
- name: archive - name: archive
description: Record-first monitoring profile description: Record-first monitoring profile
pipeline:
mode: archive
profile: archive
goals:
intent: archive-and-triage
signal_priorities: ["wfm", "broadcast", "digital"]
surveillance: surveillance:
analysis_fft_size: 4096 analysis_fft_size: 4096
frame_rate: 12 frame_rate: 12
@@ -141,26 +161,26 @@ profiles:
derived_detection: auto derived_detection: auto
resources: resources:
prefer_gpu: true 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: refinement:
enabled: true enabled: true
max_concurrent: 12
max_concurrent: 16
detail_fft_size: 4096 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 auto_span: true
pipeline:
mode: archive
profile: archive
goals:
intent: archive-and-triage
signal_priorities: ["wfm", "nfm", "digital"]
- name: digital-hunting - name: digital-hunting
description: Digital-first refinement and decode focus description: Digital-first refinement and decode focus
pipeline:
mode: digital-hunting
profile: digital-hunting
goals:
intent: digital-surveillance
signal_priorities: ["rds", "digital", "wfm"]
surveillance: surveillance:
analysis_fft_size: 4096 analysis_fft_size: 4096
frame_rate: 12 frame_rate: 12
@@ -170,32 +190,26 @@ profiles:
derived_detection: auto derived_detection: auto
resources: resources:
prefer_gpu: true 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: refinement:
enabled: true enabled: true
max_concurrent: 16
max_concurrent: 20
detail_fft_size: 4096 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 auto_span: true
pipeline:
mode: digital-hunting
profile: digital-hunting
goals:
intent: digital-surveillance
signal_priorities: ["ft8", "wspr", "fsk", "psk", "dmr"]
detector: detector:
threshold_db: -55
threshold_db: -60
min_duration_ms: 120 min_duration_ms: 120
hold_ms: 1200
hold_ms: 1500
ema_alpha: 0.35 ema_alpha: 0.35
hysteresis_db: 6 hysteresis_db: 6
min_stable_frames: 3 min_stable_frames: 3
gap_tolerance_ms: 1200
gap_tolerance_ms: 1500
cfar_mode: GOSCA cfar_mode: GOSCA
cfar_guard_hz: 15000 cfar_guard_hz: 15000
cfar_train_hz: 120000 cfar_train_hz: 120000
@@ -205,27 +219,27 @@ detector:
cfar_scale_db: 7 cfar_scale_db: 7
cfar_wrap_around: true cfar_wrap_around: true
edge_margin_db: 4 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_history_size: 10
class_switch_ratio: 0.6 class_switch_ratio: 0.6
recorder: recorder:
enabled: false
min_snr_db: 10
min_duration: 1s
enabled: true
min_snr_db: 0
min_duration: 500ms
max_duration: 300s max_duration: 300s
preroll_ms: 500 preroll_ms: 500
record_iq: false record_iq: false
record_audio: false
record_audio: true
auto_demod: true auto_demod: true
auto_decode: false
auto_decode: true
max_disk_mb: 0 max_disk_mb: 0
output_dir: data/recordings output_dir: data/recordings
class_filter: []
ring_seconds: 8
class_filter: ["WFM", "WFM_STEREO"]
ring_seconds: 12
deemphasis_us: 50 deemphasis_us: 50
extraction_fir_taps: 101 extraction_fir_taps: 101
extraction_bw_mult: 1.2
extraction_bw_mult: 1.35
decoder: decoder:
ft8_cmd: C:/WSJT/wsjtx-2.7.0-rc6/bin/jt9.exe -8 {audio} 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} wspr_cmd: C:/WSJT/wsjtx-2.7.0-rc6/bin/wsprd.exe {audio}


+ 140
- 21
internal/recorder/streamer.go 파일 보기

@@ -60,12 +60,19 @@ type streamSession struct {


// Stereo decode: phase-continuous 38kHz oscillator // Stereo decode: phase-continuous 38kHz oscillator
stereoPhase float64 stereoPhase float64
// Stereo lock state for live WFM streaming
stereoEnabled bool
stereoOnCount int
stereoOffCount int


// Polyphase resampler (replaces integer-decimate hack) // 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 // AQ-4: Stateful FIR filters for click-free stereo decode
stereoFilterRate int
stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R
stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high
stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low
@@ -222,8 +229,10 @@ func (st *Streamer) FeedSnippets(items []streamFeedItem) {
st.mu.Lock() st.mu.Lock()
recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ) recEnabled := st.policy.Enabled && (st.policy.RecordAudio || st.policy.RecordIQ)
hasListeners := st.hasListenersLocked() hasListeners := st.hasListenersLocked()
pending := len(st.pendingListens)
st.mu.Unlock() 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 { if (!recEnabled && !hasListeners) || len(items) == 0 {
return return
} }
@@ -264,12 +273,36 @@ func (st *Streamer) processFeed(msg streamFeedMsg) {
// Decide whether this signal needs a session // Decide whether this signal needs a session
needsRecording := recEnabled && sig.SNRDb >= st.policy.MinSNRDb && st.classAllowed(sig.Class) needsRecording := recEnabled && sig.SNRDb >= st.policy.MinSNRDb && st.classAllowed(sig.Class)
needsListen := st.signalHasListenerLocked(sig) 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 { if !needsRecording && !needsListen {
continue continue
} }


sess, exists := st.sessions[sig.ID] 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 !exists {
if needsRecording { if needsRecording {
s, err := st.openRecordingSession(sig, now) s, err := st.openRecordingSession(sig, now)
@@ -370,10 +403,13 @@ func (st *Streamer) processFeed(msg streamFeedMsg) {


func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool { func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool {
if sess, ok := st.sessions[sig.ID]; ok && len(sess.audioSubs) > 0 { 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 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 return true
} }
} }
@@ -382,6 +418,10 @@ func (st *Streamer) signalHasListenerLocked(sig *detector.Signal) bool {


func (st *Streamer) attachPendingListeners(sess *streamSession) { func (st *Streamer) attachPendingListeners(sess *streamSession) {
for subID, pl := range st.pendingListens { for subID, pl := range st.pendingListens {
requestedMode := normalizeRequestedMode(pl.mode)
if requestedMode != "" && sess.demodName != requestedMode {
continue
}
if math.Abs(sess.centerHz-pl.freq) < 200000 { if math.Abs(sess.centerHz-pl.freq) < 200000 {
sess.audioSubs = append(sess.audioSubs, audioSub{id: subID, ch: pl.ch}) sess.audioSubs = append(sess.audioSubs, audioSub{id: subID, ch: pl.ch})
delete(st.pendingListens, subID) delete(st.pendingListens, subID)
@@ -453,10 +493,15 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64
st.nextSub++ st.nextSub++
subID := st.nextSub subID := st.nextSub


requestedMode := normalizeRequestedMode(mode)

// Try to find a matching session // Try to find a matching session
var bestSess *streamSession var bestSess *streamSession
bestDist := math.MaxFloat64 bestDist := math.MaxFloat64
for _, sess := range st.sessions { for _, sess := range st.sessions {
if requestedMode != "" && sess.demodName != requestedMode {
continue
}
d := math.Abs(sess.centerHz - freq) d := math.Abs(sess.centerHz - freq)
if d < bestDist { if d < bestDist {
bestDist = d bestDist = d
@@ -491,6 +536,7 @@ func (st *Streamer) SubscribeAudio(freq float64, bw float64, mode string) (int64
DemodName: "NFM", DemodName: "NFM",
} }
log.Printf("STREAM: subscriber %d pending (freq=%.1fMHz)", subID, freq/1e6) 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 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 channels := 1
if isWFMStereo { 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 --- // --- Polyphase resample to exact 48kHz ---
if actualDemodRate != streamAudioRate { if actualDemodRate != streamAudioRate {
if channels > 1 { if channels > 1 {
if sess.stereoResampler == nil {
if sess.stereoResampler == nil || sess.stereoResamplerRate != actualDemodRate {
sess.stereoResampler = dsp.NewStereoResampler(actualDemodRate, streamAudioRate, resamplerTaps) sess.stereoResampler = dsp.NewStereoResampler(actualDemodRate, streamAudioRate, resamplerTaps)
sess.stereoResamplerRate = actualDemodRate
} }
audio = sess.stereoResampler.Process(audio) audio = sess.stereoResampler.Process(audio)
} else { } else {
if sess.monoResampler == nil {
if sess.monoResampler == nil || sess.monoResamplerRate != actualDemodRate {
sess.monoResampler = dsp.NewResampler(actualDemodRate, streamAudioRate, resamplerTaps) sess.monoResampler = dsp.NewResampler(actualDemodRate, streamAudioRate, resamplerTaps)
sess.monoResamplerRate = actualDemodRate
} }
audio = sess.monoResampler.Process(audio) 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 return audio, streamAudioRate
} }


// stereoDecodeStateful: phase-continuous 38kHz oscillator for L-R extraction. // stereoDecodeStateful: phase-continuous 38kHz oscillator for L-R extraction.
// AQ-4: Uses persistent FIR filter state across frames for click-free stereo. // AQ-4: Uses persistent FIR filter state across frames for click-free stereo.
// Reuses session scratch buffers to minimize allocations. // 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 { if len(mono) == 0 || sampleRate <= 0 {
return nil
return nil, false
} }
n := len(mono) 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) lp := dsp.LowpassFIR(15000, sampleRate, 101)
sess.stereoLPF = dsp.NewStatefulFIRReal(lp) sess.stereoLPF = dsp.NewStatefulFIRReal(lp)
sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101)) sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101))
sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101)) sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101))
sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp) sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp)
sess.stereoFilterRate = sampleRate
} }


// Reuse scratch for intermediates: need 4*n float32 for bpf, lr, hi, lo // 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 phase := sess.stereoPhase
inc := 2 * math.Pi * 38000 / float64(sampleRate) inc := 2 * math.Pi * 38000 / float64(sampleRate)
var pilotPower float64
var totalPower float64
for i := range bpf { for i := range bpf {
phase += inc 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) sess.stereoPhase = math.Mod(phase, 2*math.Pi)


lr = sess.stereoLRLPF.Process(lr) lr = sess.stereoLRLPF.Process(lr)
locked := totalPower > 0 && (pilotPower/totalPower) > 0.12


out := make([]float32, n*2) out := make([]float32, n*2)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
out[i*2] = 0.5 * (lpr[i] + lr[i]) out[i*2] = 0.5 * (lpr[i] + lr[i])
out[i*2+1] = 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. // dspStateSnapshot captures persistent DSP state for segment splits.
@@ -714,9 +798,12 @@ type dspStateSnapshot struct {
deemphL float64 deemphL float64
deemphR float64 deemphR float64
stereoPhase 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 stereoBPHi *dsp.StatefulFIRReal
stereoBPLo *dsp.StatefulFIRReal stereoBPLo *dsp.StatefulFIRReal
stereoLRLPF *dsp.StatefulFIRReal stereoLRLPF *dsp.StatefulFIRReal
@@ -733,9 +820,12 @@ func (sess *streamSession) captureDSPState() dspStateSnapshot {
deemphL: sess.deemphL, deemphL: sess.deemphL,
deemphR: sess.deemphR, deemphR: sess.deemphR,
stereoPhase: sess.stereoPhase, 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, stereoBPHi: sess.stereoBPHi,
stereoBPLo: sess.stereoBPLo, stereoBPLo: sess.stereoBPLo,
stereoLRLPF: sess.stereoLRLPF, stereoLRLPF: sess.stereoLRLPF,
@@ -753,8 +843,11 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) {
sess.deemphR = s.deemphR sess.deemphR = s.deemphR
sess.stereoPhase = s.stereoPhase sess.stereoPhase = s.stereoPhase
sess.monoResampler = s.monoResampler sess.monoResampler = s.monoResampler
sess.monoResamplerRate = s.monoResamplerRate
sess.stereoResampler = s.stereoResampler sess.stereoResampler = s.stereoResampler
sess.stereoResamplerRate = s.stereoResamplerRate
sess.stereoLPF = s.stereoLPF sess.stereoLPF = s.stereoLPF
sess.stereoFilterRate = s.stereoFilterRate
sess.stereoBPHi = s.stereoBPHi sess.stereoBPHi = s.stereoBPHi
sess.stereoBPLo = s.stereoBPLo sess.stereoBPLo = s.stereoBPLo
sess.stereoLRLPF = s.stereoLRLPF 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 { func (st *Streamer) openListenSession(sig *detector.Signal, now time.Time) *streamSession {
demodName, channels := resolveDemod(sig) 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{ sess := &streamSession{
signalID: sig.ID, signalID: sig.ID,
@@ -857,6 +965,17 @@ func resolveDemod(sig *detector.Signal) (string, int) {
return demodName, channels 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. // growIQ returns a complex64 slice of at least n elements, reusing sess.scratchIQ.
func (sess *streamSession) growIQ(n int) []complex64 { func (sess *streamSession) growIQ(n int) []complex64 {
if cap(sess.scratchIQ) >= n { if cap(sess.scratchIQ) >= n {


불러오는 중...
취소
저장