mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-15 10:07:26 -05:00
#2662 Tuners monitor sample rate and log on excessive dropped samples.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ public abstract class Tuner implements ISourceEventProcessor, ITunerErrorListene
|
||||
try
|
||||
{
|
||||
getTunerController().start();
|
||||
getTunerController().setSampleDropMonitorTunerName(getPreferredName());
|
||||
}
|
||||
catch(SourceException se)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -130,7 +130,7 @@ public abstract class RspTunerController<I extends IControlRsp> extends TunerCon
|
||||
|
||||
for(RspNativeBuffer buffer: buffers)
|
||||
{
|
||||
mNativeBufferBroadcaster.broadcast(buffer);
|
||||
broadcast(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user