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());
+ }
+}