Просмотр исходного кода

debug: instrument audio click investigation

debug/audio-clicks
Jan Svabenik 1 день назад
Родитель
Сommit
94c132d6fc
5 измененных файлов: 100 добавлений и 27 удалений
  1. +8
    -0
      cmd/sdrd/dsp_loop.go
  2. +29
    -2
      cmd/sdrd/helpers.go
  3. +29
    -1
      cmd/sdrd/pipeline_runtime.go
  4. +7
    -0
      internal/recorder/recorder.go
  5. +27
    -24
      internal/recorder/streamer.go

+ 8
- 0
cmd/sdrd/dsp_loop.go Просмотреть файл

@@ -75,6 +75,14 @@ func runDSP(ctx context.Context, srcMgr *sourceManager, cfg config.Config, det *
streamSignals = stableSignals
}
if rec != nil && len(art.allIQ) > 0 {
if art.streamDropped {
rt.streamOverlap = &streamIQOverlap{}
for k := range rt.streamPhaseState {
rt.streamPhaseState[k].phase = 0
}
rec.ResetStreams()
logging.Warn("gap", "iq_dropped", "msg", "buffer bloat caused extraction drop; overlap reset")
}
if rt.cfg.Recorder.DebugLiveAudio {
log.Printf("LIVEAUDIO DSP: detailIQ=%d displaySignals=%d streamSignals=%d stableSignals=%d allIQ=%d", len(art.detailIQ), len(displaySignals), len(streamSignals), len(stableSignals), len(art.allIQ))
}


+ 29
- 2
cmd/sdrd/helpers.go Просмотреть файл

@@ -3,8 +3,10 @@ package main
import (
"log"
"math"
"os"
"sort"
"strconv"
"strings"
"time"

"sdr-wideband-suite/internal/config"
@@ -231,6 +233,18 @@ const (
wfmStreamMinBW = 250000
)

var forceCPUStreamExtract = func() bool {
raw := strings.TrimSpace(os.Getenv("SDR_FORCE_CPU_STREAM_EXTRACT"))
if raw == "" {
return false
}
v, err := strconv.ParseBool(raw)
if err != nil {
return false
}
return v
}()

// extractForStreaming performs GPU-accelerated extraction with:
// - Per-signal phase-continuous FreqShift (via PhaseStart in ExtractJob)
// - IQ overlap prepended to allIQ so FIR kernel has real data in halo
@@ -325,8 +339,13 @@ func extractForStreaming(
}
}

// Try GPU BatchRunner with phase
runner := extractMgr.get(len(gpuIQ), sampleRate)
// Try GPU BatchRunner with phase unless CPU-only debug is forced.
var runner *gpudemod.BatchRunner
if forceCPUStreamExtract {
logging.Warn("boundary", "force_cpu_stream_extract", "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "signals", len(signals))
} else {
runner = extractMgr.get(len(gpuIQ), sampleRate)
}
if runner != nil {
results, err := runner.ShiftFilterDecimateBatchWithPhase(gpuIQ, jobs)
if err == nil && len(results) == len(signals) {
@@ -356,9 +375,13 @@ func extractForStreaming(

// Trim overlap from output
iq := res.IQ
rawLen := len(iq)
if trimSamples > 0 && trimSamples < len(iq) {
iq = iq[trimSamples:]
}
if i == 0 {
logging.Debug("boundary", "extract_trim", "path", "gpu", "raw_len", rawLen, "trim", trimSamples, "out_len", len(iq), "overlap_len", overlapLen, "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "outRate", outRate, "signal", signals[i].ID)
}
out[i] = iq
rates[i] = res.Rate
}
@@ -424,9 +447,13 @@ func extractForStreaming(
if i == 0 {
logging.Debug("extract", "cpu_result", "outRate", outRate, "decim", decim, "trim", trimSamples)
}
rawLen := len(decimated)
if trimSamples > 0 && trimSamples < len(decimated) {
decimated = decimated[trimSamples:]
}
if i == 0 {
logging.Debug("boundary", "extract_trim", "path", "cpu", "raw_len", rawLen, "trim", trimSamples, "out_len", len(decimated), "overlap_len", overlapLen, "allIQ_len", len(allIQ), "gpuIQ_len", len(gpuIQ), "outRate", outRate, "signal", signals[i].ID)
}
out[i] = decimated
}
return out, rates


+ 29
- 1
cmd/sdrd/pipeline_runtime.go Просмотреть файл

@@ -3,6 +3,8 @@ package main
import (
"fmt"
"math"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -29,6 +31,18 @@ type rdsState struct {
mu sync.Mutex
}

var forceFixedStreamReadSamples = func() int {
raw := strings.TrimSpace(os.Getenv("SDR_FORCE_FIXED_STREAM_READ_SAMPLES"))
if raw == "" {
return 0
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return 0
}
return v
}()

type dspRuntime struct {
cfg config.Config
det *detector.Detector
@@ -56,6 +70,7 @@ type dspRuntime struct {

type spectrumArtifacts struct {
allIQ []complex64
streamDropped bool
surveillanceIQ []complex64
detailIQ []complex64
surveillanceSpectrum []float64
@@ -341,7 +356,17 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag
}
available := required
st := srcMgr.Stats()
if st.BufferSamples > required {
if forceFixedStreamReadSamples > 0 {
available = forceFixedStreamReadSamples
if available < required {
available = required
}
available = (available / required) * required
if available < required {
available = required
}
logging.Warn("boundary", "fixed_stream_read_samples", "configured", forceFixedStreamReadSamples, "effective", available, "required", required)
} else if st.BufferSamples > required {
available = (st.BufferSamples / required) * required
if available < required {
available = required
@@ -366,8 +391,10 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag
maxStreamSamples = required
}
maxStreamSamples = (maxStreamSamples / required) * required
streamDropped := false
if len(allIQ) > maxStreamSamples {
allIQ = allIQ[len(allIQ)-maxStreamSamples:]
streamDropped = true
}
logging.Debug("capture", "iq_len", "len", len(allIQ), "surv_fft", rt.cfg.FFTSize, "detail_fft", rt.detailFFT)
survIQ := allIQ
@@ -432,6 +459,7 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag
finished, detected := rt.det.Process(now, survSpectrum, rt.cfg.CenterHz)
return &spectrumArtifacts{
allIQ: allIQ,
streamDropped: streamDropped,
surveillanceIQ: survIQ,
detailIQ: detailIQ,
surveillanceSpectrum: survSpectrum,


+ 7
- 0
internal/recorder/recorder.go Просмотреть файл

@@ -357,6 +357,13 @@ func (m *Manager) StreamerRef() *Streamer {
return m.streamer
}

func (m *Manager) ResetStreams() {
if m == nil || m.streamer == nil {
return
}
m.streamer.ResetStreams()
}

func (m *Manager) RuntimeInfoBySignalID() map[int64]RuntimeSignalInfo {
if m == nil || m.streamer == nil {
return nil


+ 27
- 24
internal/recorder/streamer.go Просмотреть файл

@@ -60,6 +60,8 @@ type streamSession struct {
// --- Persistent DSP state for click-free streaming ---

// Overlap-save: tail of previous extracted IQ snippet.
// Currently unused for live demod after removing the extra discriminator
// overlap prepend, but kept in DSP snapshot state for compatibility.
overlapIQ []complex64

// De-emphasis IIR state (persists across frames)
@@ -731,26 +733,13 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([]
return nil, 0
}

// --- FM discriminator overlap: prepend 1 sample from previous frame ---
// The FM discriminator needs iq[i-1] to compute the first output.
// All FIR filtering is now stateful, so no additional overlap is needed.
var fullSnip []complex64
trimSamples := 0
_ = trimSamples
if len(sess.overlapIQ) == 1 {
fullSnip = make([]complex64, 1+len(snippet))
fullSnip[0] = sess.overlapIQ[0]
copy(fullSnip[1:], snippet)
trimSamples = 1
logging.Debug("discrim", "overlap_applied", "signal", sess.signalID, "snip", len(snippet))
} else {
fullSnip = snippet
}

// Save last sample for next frame's FM discriminator
if len(snippet) > 0 {
sess.overlapIQ = []complex64{snippet[len(snippet)-1]}
}
// The extra 1-sample discriminator overlap prepend was removed after it was
// shown to shift the downstream decimation phase and create heavy click
// artifacts in steady-state streaming/recording. The upstream extraction path
// and the stateful FIR/decimation stages already provide continuity.
fullSnip := snippet
overlapApplied := false
prevTailValid := false

// --- Stateful anti-alias FIR + decimation to demod rate ---
demodRate := d.OutputSampleRate()
@@ -788,20 +777,21 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([]
sess.preDemodDecimPhase = 0
}

decimPhaseBefore := sess.preDemodDecimPhase
filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip)))
dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase)
logging.Debug("boundary", "snippet_path", "signal", sess.signalID, "overlap_applied", overlapApplied, "snip_len", len(snippet), "full_len", len(fullSnip), "filtered_len", len(filtered), "dec_len", len(dec), "decim1", decim1, "phase_before", decimPhaseBefore, "phase_after", sess.preDemodDecimPhase)
} else {
logging.Debug("boundary", "snippet_path", "signal", sess.signalID, "overlap_applied", overlapApplied, "snip_len", len(snippet), "full_len", len(fullSnip), "filtered_len", len(fullSnip), "dec_len", len(fullSnip), "decim1", decim1, "phase_before", 0, "phase_after", 0)
dec = fullSnip
}

// --- FM Demod ---
// --- FM/AM/etc Demod ---
audio := d.Demod(dec, actualDemodRate)
if len(audio) == 0 {
return nil, 0
}

// --- Trim the 1-sample FM discriminator overlap ---
// TEMP: skip audio trim to test if per-block trimming causes ticks
logging.Debug("boundary", "audio_path", "signal", sess.signalID, "demod", demodName, "actual_rate", actualDemodRate, "audio_len", len(audio), "channels", d.Channels(), "overlap_applied", overlapApplied, "prev_tail_valid", prevTailValid)

// --- Stateful stereo decode with conservative lock/hysteresis ---
channels := 1
@@ -1483,3 +1473,16 @@ func fixStreamWAVHeader(f *os.File, totalSamples int64, sampleRate int, channels
}
_, _ = f.Write(buf[:])
}

// ResetStreams forces all active streaming sessions to discard their FIR states and decimation phases.
// This is used when the upstream DSP drops samples, creating a hard break in phase continuity.
func (st *Streamer) ResetStreams() {
st.mu.Lock()
defer st.mu.Unlock()
for _, sess := range st.sessions {
sess.preDemodFIR = nil
sess.preDemodDecimPhase = 0
sess.stereoResampler = nil
sess.monoResampler = nil
}
}

Загрузка…
Отмена
Сохранить