From 74bb30605375f17ba316eb631fdf5bc12811ee16 Mon Sep 17 00:00:00 2001 From: denny Date: Sun, 15 Nov 2026 08:03:34 -0500 Subject: [PATCH] #2662 Tuners monitor sample rate and log on excessive dropped samples. --- .../source/tuner/SampleDropMonitor.java | 206 ++++++++++++++++++ .../github/dsheirer/source/tuner/Tuner.java | 1 + .../source/tuner/TunerController.java | 40 ++++ .../source/tuner/fcd/FCDTunerController.java | 2 +- .../tuner/limesdr/LimeSdrTunerController.java | 10 + .../recording/RecordingTunerController.java | 9 + .../tuner/sdrplay/RspTunerController.java | 2 +- .../tuner/test/TestTunerController.java | 9 + .../source/tuner/SampleDropMonitorTest.java | 146 +++++++++++++ 9 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/source/tuner/SampleDropMonitor.java create mode 100644 src/test/java/io/github/dsheirer/source/tuner/SampleDropMonitorTest.java diff --git a/src/main/java/io/github/dsheirer/source/tuner/SampleDropMonitor.java b/src/main/java/io/github/dsheirer/source/tuner/SampleDropMonitor.java new file mode 100644 index 00000000..7472e3c8 --- /dev/null +++ b/src/main/java/io/github/dsheirer/source/tuner/SampleDropMonitor.java @@ -0,0 +1,206 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.source.tuner; + +import java.text.DecimalFormat; +import java.util.function.DoubleSupplier; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight sample drop detector for tuners that don't provide sample counters or hardware timestamps. Counts the + * samples delivered by the tuner and compares the count against the number of samples expected from the sample rate + * and the elapsed (host) time. Logs a warning when the tuner consistently delivers fewer samples than expected, which + * indicates that samples are being dropped (ie by the USB bus or because the host isn't keeping up). + * + * Detection: each evaluation window (5 seconds) produces a sample deficit (expected minus received). The deficit is + * accumulated with a cumulative sum that subtracts an allowance for the clock difference between the tuner and the + * host, and that never goes below zero. Buffer delivery jitter (ie a delayed buffer) adds a deficit to one window + * that the following window gives back, while dropped samples are never given back. So drops are only declared + * when the accumulated deficit exceeds the threshold at two consecutive evaluations. + * + * The stream is assumed to be stopped (ie no buffer listeners) or restarted when buffers stop arriving for longer than + * the restart gap, and detection restarts with the next buffer. Detection also restarts when the sample rate changes. + * + * Note: process() is invoked on the tuner's sample stream thread and is not thread safe. + */ +public class SampleDropMonitor +{ + private static final Logger LOG = LoggerFactory.getLogger(SampleDropMonitor.class); + private static final DecimalFormat PERCENT_FORMAT = new DecimalFormat("0.00"); + private static final long NANOS_PER_SECOND = 1_000_000_000L; + //Evaluation window + static final long WINDOW_NANOS = 5 * NANOS_PER_SECOND; + //Buffer arrival gap that indicates the stream was stopped and restarted + static final long RESTART_GAP_NANOS = 2 * NANOS_PER_SECOND; + //Minimum interval between warnings, so that drops are summarized instead of logged every window + static final long LOG_INTERVAL_NANOS = 60 * NANOS_PER_SECOND; + //Allowance for the tuner versus host clock difference and sample rate rounding (parts per million). Drop rates + //below this aren't detected. + static final double CLOCK_ALLOWANCE = 500.0 / 1_000_000.0; + //Accumulated deficit that indicates dropped samples, in milliseconds of samples + static final double THRESHOLD_MILLISECONDS = 20.0; + + private final DoubleSupplier mSampleRateSupplier; + private final Supplier mTunerNameSupplier; + private boolean mStarted = false; + private long mWindowStart; + private long mLastBuffer; + private long mLastLog; + private long mWindowSampleCount; + private double mSampleRate; + private double mAccumulatedDeficit; + private boolean mThresholdExceeded; + //Start of the period summarized by the next warning: the detection (re)start or the previous warning. + private long mReportStart; + private double mUnreportedDroppedSamples; + private long mTotalDroppedSamples; + + /** + * Constructs an instance + * @param tunerNameSupplier provides the tuner name for log messages. + * @param sampleRateSupplier provides the tuner's current sample rate. + */ + public SampleDropMonitor(Supplier tunerNameSupplier, DoubleSupplier sampleRateSupplier) + { + mTunerNameSupplier = tunerNameSupplier; + mSampleRateSupplier = sampleRateSupplier; + } + + /** + * Total number of samples detected as dropped since this monitor was created. + */ + public long getTotalDroppedSamples() + { + return mTotalDroppedSamples; + } + + /** + * Processes a buffer of samples received from the tuner. + * @param sampleCount number of (complex) samples in the buffer. + */ + public void process(int sampleCount) + { + process(sampleCount, System.nanoTime()); + } + + /** + * Processes a buffer of samples received from the tuner. + * @param sampleCount number of (complex) samples in the buffer. + * @param now arrival time in nanoseconds (System.nanoTime()). + */ + void process(int sampleCount, long now) + { + if(!mStarted || now - mLastBuffer > RESTART_GAP_NANOS) + { + restart(now); + return; + } + + mLastBuffer = now; + //The samples of each buffer arriving within the window (after the window's starting buffer) were produced during + //the window. + mWindowSampleCount += sampleCount; + + long elapsed = now - mWindowStart; + + if(elapsed >= WINDOW_NANOS) + { + evaluate(now, elapsed); + } + } + + /** + * Restarts detection. The buffer at the restart time starts the window and isn't counted, since its samples were + * produced before the window start. + */ + private void restart(long now) + { + mStarted = true; + mWindowStart = now; + mLastBuffer = now; + mLastLog = now - LOG_INTERVAL_NANOS; + mReportStart = now; + mUnreportedDroppedSamples = 0.0; + mWindowSampleCount = 0; + mSampleRate = mSampleRateSupplier.getAsDouble(); + mAccumulatedDeficit = 0.0; + mThresholdExceeded = false; + } + + /** + * Evaluates the completed window and starts the next window. + */ + private void evaluate(long now, long elapsed) + { + double sampleRate = mSampleRateSupplier.getAsDouble(); + + if(sampleRate != mSampleRate || sampleRate <= 0) + { + restart(now); + return; + } + + double expected = sampleRate * elapsed / NANOS_PER_SECOND; + double deficit = expected - mWindowSampleCount; + mAccumulatedDeficit = Math.max(0.0, mAccumulatedDeficit + deficit - (expected * CLOCK_ALLOWANCE)); + + if(mAccumulatedDeficit > sampleRate * THRESHOLD_MILLISECONDS / 1000.0) + { + //Require the deficit to persist across a second window to rule out buffer delivery jitter. + if(mThresholdExceeded) + { + mUnreportedDroppedSamples += mAccumulatedDeficit; + mTotalDroppedSamples += (long)mAccumulatedDeficit; + mAccumulatedDeficit = 0.0; + mThresholdExceeded = false; + } + else + { + mThresholdExceeded = true; + } + } + else + { + mThresholdExceeded = false; + } + + if(mUnreportedDroppedSamples > 0 && now - mLastLog >= LOG_INTERVAL_NANOS) + { + double period = (double)(now - mReportStart) / NANOS_PER_SECOND; + double milliseconds = mUnreportedDroppedSamples / sampleRate * 1000.0; + double percent = mUnreportedDroppedSamples / (sampleRate * period) * 100.0; + LOG.warn("Tuner [" + mTunerNameSupplier.get() + "] is dropping samples - approximately [" + + (long)mUnreportedDroppedSamples + "] samples (" + Math.round(milliseconds) + " ms, " + + PERCENT_FORMAT.format(percent) + "% of the sample stream) were lost over the last " + + Math.round(period) + " seconds. The USB bus or the computer isn't keeping up with the tuner's " + + "sample rate. Dropped samples cause decoders to lose sync and cause choppy audio. Try a " + + "different USB port or USB controller, avoid USB hubs and extension cables, don't share the USB " + + "controller with other tuners, or lower the tuner sample rate."); + mLastLog = now; + mReportStart = now; + mUnreportedDroppedSamples = 0.0; + } + + mWindowStart = now; + mWindowSampleCount = 0; + } +} diff --git a/src/main/java/io/github/dsheirer/source/tuner/Tuner.java b/src/main/java/io/github/dsheirer/source/tuner/Tuner.java index 674234d7..18f4de64 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/Tuner.java +++ b/src/main/java/io/github/dsheirer/source/tuner/Tuner.java @@ -89,6 +89,7 @@ public abstract class Tuner implements ISourceEventProcessor, ITunerErrorListene try { getTunerController().start(); + getTunerController().setSampleDropMonitorTunerName(getPreferredName()); } catch(SourceException se) { diff --git a/src/main/java/io/github/dsheirer/source/tuner/TunerController.java b/src/main/java/io/github/dsheirer/source/tuner/TunerController.java index 73e0ce5f..e1e1842c 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/TunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/TunerController.java @@ -63,6 +63,8 @@ public abstract class TunerController implements Tunable, ISourceEventProcessor, private ITunerErrorListener mTunerErrorListener; private static final DecimalFormat DF = new DecimalFormat("0.000"); protected TunerFrequencyErrorManager mTunerFrequencyErrorManager; + private final SampleDropMonitor mSampleDropMonitor; + private volatile String mSampleDropMonitorTunerName; /** * Abstract tuner controller class. The tuner controller manages frequency bandwidth and currently tuned channels @@ -75,6 +77,39 @@ public abstract class TunerController implements Tunable, ISourceEventProcessor, mFrequencyController = new FrequencyController(this); mSourceEventListener = new SourceEventListenerToProcessorAdapter(this); mTunerFrequencyErrorManager = new TunerFrequencyErrorManager(this); + mSampleDropMonitor = isSampleDropMonitorSupported() ? new SampleDropMonitor(this::getSampleDropMonitorTunerName, + this::getSampleRate) : null; + } + + /** + * Indicates if the tuner's sample stream should be monitored for dropped samples by counting the samples delivered + * against the sample rate. Override to disable for sources that aren't real-time (ie recordings) or for tuners + * that detect dropped samples from hardware sample counters or timestamps. + * + * Note: invoked from this class's constructor, so overrides must not depend on subclass state. + * @return true to monitor (default). + */ + protected boolean isSampleDropMonitorSupported() + { + return true; + } + + /** + * Sets the tuner name used when logging dropped sample warnings. + * @param tunerName to log + */ + public void setSampleDropMonitorTunerName(String tunerName) + { + mSampleDropMonitorTunerName = tunerName; + } + + /** + * Tuner name for dropped sample warnings: the name set by the tuner, or the tuner type. + */ + private String getSampleDropMonitorTunerName() + { + String name = mSampleDropMonitorTunerName; + return name != null ? name : String.valueOf(getTunerType()); } /** @@ -684,6 +719,11 @@ public abstract class TunerController implements Tunable, ISourceEventProcessor, */ protected void broadcast(INativeBuffer complexSamples) { + if(mSampleDropMonitor != null) + { + mSampleDropMonitor.process(complexSamples.sampleCount()); + } + //Note: unprotected access to the broadcaster ... the broadcaster uses thread-save internal list mNativeBufferBroadcaster.broadcast(complexSamples); } diff --git a/src/main/java/io/github/dsheirer/source/tuner/fcd/FCDTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/fcd/FCDTunerController.java index b39b9488..5d315766 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/fcd/FCDTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/fcd/FCDTunerController.java @@ -99,7 +99,7 @@ public abstract class FCDTunerController extends TunerController mComplexMixer = new ComplexMixer(mixerTDL, tunerType.getAudioFormat(), tunerType.getDisplayString(), new ComplexShortAdapter()); mComplexMixer.setBufferSampleCount(getBufferSampleCount()); - mComplexMixer.setBufferListener(mNativeBufferBroadcaster); + mComplexMixer.setBufferListener(this); } @Override diff --git a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java index b3136ac6..85c713e3 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/limesdr/LimeSdrTunerController.java @@ -151,6 +151,16 @@ public class LimeSdrTunerController extends TunerController setUsableBandwidthPercentage(USABLE_BANDWIDTH_PERCENT); } + /** + * Disables the generic sample drop monitor since dropped samples are counted exactly from the hardware sample + * timestamps (see countDroppedSamples()). + */ + @Override + protected boolean isSampleDropMonitorSupported() + { + return false; + } + @Override public TunerType getTunerType() { diff --git a/src/main/java/io/github/dsheirer/source/tuner/recording/RecordingTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/recording/RecordingTunerController.java index 417a46cf..ec93bee0 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/recording/RecordingTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/recording/RecordingTunerController.java @@ -129,6 +129,15 @@ public class RecordingTunerController extends TunerController } } + /** + * Disables the generic sample drop monitor since recordings are replayed and not limited to a real-time sample rate. + */ + @Override + protected boolean isSampleDropMonitorSupported() + { + return false; + } + @Override public TunerType getTunerType() { diff --git a/src/main/java/io/github/dsheirer/source/tuner/sdrplay/RspTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/sdrplay/RspTunerController.java index eff2478c..5e6bd397 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/sdrplay/RspTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/sdrplay/RspTunerController.java @@ -130,7 +130,7 @@ public abstract class RspTunerController extends TunerCon for(RspNativeBuffer buffer: buffers) { - mNativeBufferBroadcaster.broadcast(buffer); + broadcast(buffer); } } diff --git a/src/main/java/io/github/dsheirer/source/tuner/test/TestTunerController.java b/src/main/java/io/github/dsheirer/source/tuner/test/TestTunerController.java index 508578c0..542a1115 100644 --- a/src/main/java/io/github/dsheirer/source/tuner/test/TestTunerController.java +++ b/src/main/java/io/github/dsheirer/source/tuner/test/TestTunerController.java @@ -90,6 +90,15 @@ public class TestTunerController extends TunerController //No-op } + /** + * Disables the generic sample drop monitor since the generated test sample stream is not a hardware sample stream. + */ + @Override + protected boolean isSampleDropMonitorSupported() + { + return false; + } + @Override public TunerType getTunerType() { diff --git a/src/test/java/io/github/dsheirer/source/tuner/SampleDropMonitorTest.java b/src/test/java/io/github/dsheirer/source/tuner/SampleDropMonitorTest.java new file mode 100644 index 00000000..cdc1ac33 --- /dev/null +++ b/src/test/java/io/github/dsheirer/source/tuner/SampleDropMonitorTest.java @@ -0,0 +1,146 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.source.tuner; + +import java.util.Random; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Drives the sample drop monitor with simulated buffer arrival times. + */ +public class SampleDropMonitorTest +{ + private static final double SAMPLE_RATE = 10_000_000.0; + private static final int BUFFER = 65_536; + private static final double BUFFER_NANOS = BUFFER / SAMPLE_RATE * 1e9; + + /** + * Simulated tuner stream: buffers are produced at the sample rate (scaled by the tuner clock error) and arrive + * with optional delivery jitter. + */ + private static class Stream + { + private final SampleDropMonitor mMonitor = new SampleDropMonitor(() -> "test", () -> SAMPLE_RATE); + private final Random mRandom = new Random(42); + private final double mClockScale; + private final double mJitterNanos; + private double mProduced = 1_000_000_000.0; + + Stream(double clockPpm, double jitterNanos) + { + mClockScale = 1.0 + clockPpm / 1e6; + mJitterNanos = jitterNanos; + } + + /** + * Produces buffers for the duration, delivering all of them. + */ + void run(double seconds) + { + run(seconds, 0); + } + + /** + * Produces buffers for the duration, dropping one of every dropInterval buffers (0 = none). + */ + void run(double seconds, int dropInterval) + { + long count = (long)(seconds * 1e9 / BUFFER_NANOS); + + for(long x = 1; x <= count; x++) + { + mProduced += BUFFER_NANOS / mClockScale; + + if(dropInterval > 0 && x % dropInterval == 0) + { + continue; + } + + double jitter = mJitterNanos > 0 ? mRandom.nextDouble() * mJitterNanos : 0; + mMonitor.process(BUFFER, (long)(mProduced + jitter)); + } + } + + void pause(double seconds) + { + mProduced += seconds * 1e9; + } + + long dropped() + { + return mMonitor.getTotalDroppedSamples(); + } + } + + @Test + void cleanStreamWithClockErrorAndJitterHasNoDrops() + { + Stream stream = new Stream(-100, 30_000_000); + stream.run(600); + assertEquals(0, stream.dropped()); + + stream = new Stream(100, 30_000_000); + stream.run(600); + assertEquals(0, stream.dropped()); + } + + @Test + void singleDeliveryStallIsNotADrop() + { + Stream stream = new Stream(0, 0); + stream.run(30); + //A 500 ms delivery stall: the buffers produced during the stall are all delivered when it ends + double stallEnd = stream.mProduced + 0.5e9; + + while(stream.mProduced + BUFFER_NANOS < stallEnd) + { + stream.mProduced += BUFFER_NANOS; + stream.mMonitor.process(BUFFER, (long)stallEnd); + } + + stream.run(30); + assertEquals(0, stream.dropped()); + } + + @Test + void droppedBuffersAreDetected() + { + Stream stream = new Stream(50, 10_000_000); + stream.run(60); + //Drop 1 of every 100 buffers (1%) for 60 seconds = ~0.6 seconds of samples + stream.run(60, 100); + stream.run(30); + double expected = 60.0 * SAMPLE_RATE / 100.0; + double dropped = stream.dropped(); + assertTrue(Math.abs(dropped - expected) < expected * 0.1, "Dropped " + dropped + " expected " + expected); + } + + @Test + void streamRestartIsNotADrop() + { + Stream stream = new Stream(0, 0); + stream.run(20); + stream.pause(10); + stream.run(20); + assertEquals(0, stream.dropped()); + } +}