#2662 Tuners monitor sample rate and log on excessive dropped samples.

This commit is contained in:
denny
2026-11-15 08:03:34 -05:00
parent 5474a5a448
commit 74bb306053
9 changed files with 423 additions and 2 deletions

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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<String> 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<String> 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;
}
}

View File

@@ -89,6 +89,7 @@ public abstract class Tuner implements ISourceEventProcessor, ITunerErrorListene
try
{
getTunerController().start();
getTunerController().setSampleDropMonitorTunerName(getPreferredName());
}
catch(SourceException se)
{

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -130,7 +130,7 @@ public abstract class RspTunerController<I extends IControlRsp> extends TunerCon
for(RspNativeBuffer buffer: buffers)
{
mNativeBufferBroadcaster.broadcast(buffer);
broadcast(buffer);
}
}

View File

@@ -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()
{

View File

@@ -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 <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
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());
}
}