diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 6c684a5..16b56f0 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -312,6 +312,15 @@ func main() { _ = json.NewEncoder(w).Encode(next) }) + http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if sp, ok := src.(sdr.StatsProvider); ok { + _ = json.NewEncoder(w).Encode(sp.Stats()) + return + } + _ = json.NewEncoder(w).Encode(sdr.SourceStats{}) + }) + http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") limit := 200 diff --git a/internal/mock/source.go b/internal/mock/source.go index 412966b..7481025 100644 --- a/internal/mock/source.go +++ b/internal/mock/source.go @@ -5,6 +5,8 @@ import ( "math/rand" "sync" "time" + + "sdr-visual-suite/internal/sdr" ) type Source struct { @@ -55,3 +57,7 @@ func (s *Source) ReadIQ(n int) ([]complex64, error) { } return out, nil } + +func (s *Source) Stats() sdr.SourceStats { + return sdr.SourceStats{} +} diff --git a/internal/sdr/source.go b/internal/sdr/source.go index f8565b0..7f77969 100644 --- a/internal/sdr/source.go +++ b/internal/sdr/source.go @@ -12,4 +12,14 @@ type ConfigurableSource interface { UpdateConfig(sampleRate int, centerHz float64, gainDb float64, agc bool, bwKHz int) error } +type SourceStats struct { + BufferSamples int `json:"buffer_samples"` + Dropped uint64 `json:"dropped"` + Resets uint64 `json:"resets"` +} + +type StatsProvider interface { + Stats() SourceStats +} + var ErrNotImplemented = errors.New("sdrplay support not built; build with -tags sdrplay or use --mock") diff --git a/internal/sdrplay/sdrplay.go b/internal/sdrplay/sdrplay.go index 31f1140..c4029a7 100644 --- a/internal/sdrplay/sdrplay.go +++ b/internal/sdrplay/sdrplay.go @@ -91,7 +91,13 @@ type Source struct { gainDb float64 agc bool buf []complex64 + capSamples int + head int + size int bwKHz int + dropped uint64 + resets uint64 + cond *sync.Cond } func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Source, error) { @@ -102,6 +108,8 @@ func New(sampleRate int, centerHz float64, gainDb float64, bwKHz int) (sdr.Sourc gainDb: gainDb, bwKHz: bwKHz, } + s.cond = sync.NewCond(&s.mu) + s.resizeBuffer(sampleRate, 0) s.handle = cgo.NewHandle(s) return s, s.configure(sampleRate, centerHz, gainDb, bwKHz) } @@ -164,6 +172,7 @@ func (s *Source) UpdateConfig(sampleRate int, centerHz float64, gainDb float64, C.sdrplay_set_fs(s.params, C.double(sampleRate)) updateReasons |= C.int(C.sdrplay_api_Update_Dev_Fs) s.sampleRate = sampleRate + s.resizeBuffer(sampleRate, 0) } if centerHz != 0 && centerHz != s.centerHz { C.sdrplay_set_rf(s.params, C.double(centerHz)) @@ -223,6 +232,75 @@ func bwEnum(khz int) C.sdrplay_api_Bw_MHzT { } } +func (s *Source) resizeBuffer(sampleRate int, fftSize int) { + capSamples := sampleRate + if fftSize > 0 && fftSize*4 > capSamples { + capSamples = fftSize * 4 + } + if capSamples < 4096 { + capSamples = 4096 + } + if s.capSamples == capSamples && len(s.buf) == capSamples { + return + } + newBuf := make([]complex64, capSamples) + // copy existing data from ring + toCopy := s.size + if toCopy > capSamples { + toCopy = capSamples + } + for i := 0; i < toCopy; i++ { + newBuf[i] = s.buf[(s.head+i)%max(1, s.capSamples)] + } + s.buf = newBuf + s.capSamples = capSamples + s.head = 0 + s.size = toCopy +} + +func (s *Source) appendRing(samples []complex64) { + if len(samples) == 0 || s.capSamples == 0 { + return + } + incoming := len(samples) + over := s.size + incoming - s.capSamples + if over > 0 { + s.head = (s.head + over) % s.capSamples + s.size -= over + s.dropped += uint64(over) + } + start := (s.head + s.size) % s.capSamples + first := min(incoming, s.capSamples-start) + copy(s.buf[start:start+first], samples[:first]) + if first < incoming { + copy(s.buf[0:incoming-first], samples[first:]) + } + s.size += incoming + if s.cond != nil { + s.cond.Broadcast() + } +} + +func (s *Source) Stats() sdr.SourceStats { + s.mu.Lock() + defer s.mu.Unlock() + return sdr.SourceStats{BufferSamples: s.size, Dropped: s.dropped, Resets: s.resets} +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + func (s *Source) Stop() error { s.mu.Lock() defer s.mu.Unlock() @@ -243,40 +321,28 @@ func (s *Source) Stop() error { } func (s *Source) ReadIQ(n int) ([]complex64, error) { - deadline := time.Now().Add(1500 * time.Millisecond) - for { - s.mu.Lock() - if len(s.buf) >= n { - out := make([]complex64, n) - copy(out, s.buf[:n]) - s.buf = s.buf[n:] - s.mu.Unlock() - return out, nil - } - s.mu.Unlock() - - remaining := time.Until(deadline) - if remaining <= 0 { - s.mu.Lock() - if len(s.buf) > 0 { - out := make([]complex64, len(s.buf)) - copy(out, s.buf) - s.buf = nil - s.mu.Unlock() - return out, errors.New("timeout waiting for full IQ buffer") - } - s.mu.Unlock() + deadline := time.Now().Add(5 * time.Second) + s.mu.Lock() + defer s.mu.Unlock() + for s.size < n { + if time.Now().After(deadline) { return nil, errors.New("timeout waiting for IQ samples") } - - select { - case buf := <-s.ch: - s.mu.Lock() - s.buf = append(s.buf, buf...) + if s.cond != nil { + s.cond.Wait() + } else { s.mu.Unlock() - case <-time.After(remaining / 4): + time.Sleep(50 * time.Millisecond) + s.mu.Lock() } } + out := make([]complex64, n) + for i := 0; i < n; i++ { + out[i] = s.buf[(s.head+i)%s.capSamples] + } + s.head = (s.head + n) % s.capSamples + s.size -= n + return out, nil } //export goStreamCallback @@ -288,7 +354,9 @@ func goStreamCallback(xi *C.short, xq *C.short, numSamples C.uint, reset C.uint, } if reset != 0 { src.mu.Lock() - src.buf = nil + src.head = 0 + src.size = 0 + src.resets++ src.mu.Unlock() } n := int(numSamples) @@ -304,11 +372,9 @@ func goStreamCallback(xi *C.short, xq *C.short, numSamples C.uint, reset C.uint, im := float32(float64(xqSlice[i]) * scale) iq[i] = complex(re, im) } - select { - case src.ch <- iq: - default: - // Drop if consumer is slow. - } + src.mu.Lock() + src.appendRing(iq) + src.mu.Unlock() } func cErr(err C.sdrplay_api_ErrT) error { diff --git a/web/app.js b/web/app.js index ae7d203..5a51d20 100644 --- a/web/app.js +++ b/web/app.js @@ -51,6 +51,7 @@ let avgSpectrum = null; let maxHold = false; let maxSpectrum = null; let lastFFTSize = null; +let stats = { buffer_samples: 0, dropped: 0, resets: 0 }; const events = []; const eventsById = new Map(); @@ -139,6 +140,17 @@ async function loadConfig() { } } +async function loadStats() { + try { + const res = await fetch('/api/stats'); + if (!res.ok) return; + const data = await res.json(); + stats = data || stats; + } catch (err) { + // ignore + } +} + function queueConfigUpdate(partial) { if (isSyncingConfig) return; pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial }; @@ -326,7 +338,7 @@ function renderSpectrum() { } const binHz = sample_rate / n; - metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin`; + metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets}`; } function renderWaterfall() { @@ -758,3 +770,4 @@ connect(); requestAnimationFrame(tick); fetchEvents(true); setInterval(() => fetchEvents(false), 2000); +setInterval(loadStats, 1000);