mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 16:47:31 -05:00
Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -19,66 +19,106 @@
|
||||
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.iir.SinglePoleIirFilter;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import org.apache.commons.math3.util.FastMath;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Adaptive squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
* Adaptive power squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
*
|
||||
* Signal power is measured in 5 millisecond blocks so that all timing is independent of the sample rate. The squelch
|
||||
* opens after consecutive blocks at or above the threshold. It closes on a 20 millisecond power average that spans at
|
||||
* least one cycle of the lowest voice frequencies, so that modulation doesn't look like a loss of signal, and which
|
||||
* responds in a fixed time regardless of how strong the signal was.
|
||||
*
|
||||
* Closing occurs in two steps. Audio is disabled as soon as the power average falls below the close threshold, which
|
||||
* limits the noise tail, while the squelch stays open for a hang period to bridge a brief signal fade without ending
|
||||
* the call.
|
||||
*
|
||||
* When auto-track is enabled, the noise floor is the lowest power from the recent intervals that look like noise, using
|
||||
* the ratio mean(power^2) / mean(power)^2, which is 2.0 for gaussian noise and approaches 1.0 for a steady carrier. The
|
||||
* threshold is held at a margin over the noise floor, so a threshold below the noise floor can't latch the squelch open.
|
||||
* The squelch stays closed until the first noise floor measurement is complete. A signal that doesn't look like noise
|
||||
* is excluded from the noise floor unless it persists for the carrier timeout, after which it's treated as the noise
|
||||
* floor so that a dead carrier doesn't hold the squelch open. The noise floor recovers when the carrier ends. A carrier
|
||||
* with voice modulation, such as a continuous broadcast, doesn't time out, and a timed out carrier that develops voice
|
||||
* modulation is removed from the noise floor.
|
||||
*
|
||||
* Initially modeled on: https://github.com/gnuradio/gnuradio/blob/master/gr-analog/lib/simple_squelch_cc_impl.cc
|
||||
*/
|
||||
public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AdaptiveSquelch.class);
|
||||
private static final long AUTO_ADJUST_PERIOD_MS = Duration.ofSeconds(5).toMillis();
|
||||
private SinglePoleIirFilter mFilter;
|
||||
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
|
||||
private static final double BLOCK_SECONDS = 0.005;
|
||||
private static final int OPEN_BLOCKS = 2; //10 milliseconds
|
||||
private static final int CLOSE_AVERAGE_BLOCKS = 4; //20 milliseconds
|
||||
private static final int HANG_BLOCKS = 20; //100 milliseconds
|
||||
private static final int POWER_INTERVAL_BLOCKS = 100; //500 milliseconds
|
||||
private static final int NOISE_WINDOW_INTERVALS = 20; //10 seconds
|
||||
private static final int CARRIER_TIMEOUT_INTERVALS = 120; //60 seconds
|
||||
private static final int MINIMUM_VOICE_INTERVALS = 3;
|
||||
private static final double NOISE_POWER_RATIO = 1.85;
|
||||
private static final float MINIMUM_SQUELCH_MARGIN = 4.0f; //6 dB
|
||||
private static final float CLOSE_THRESHOLD_RATIO = 0.5f; //3 dB below the open threshold
|
||||
private final float[] mCloseBlocks = new float[CLOSE_AVERAGE_BLOCKS];
|
||||
private int mCloseBlockPointer = 0;
|
||||
private float mCloseAccumulator = 0.0f;
|
||||
private int mBlockSize = (int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS);
|
||||
private int mBlockSampleCount = 0;
|
||||
private float mBlockAccumulator = 0.0f;
|
||||
private int mOpenCount = 0;
|
||||
private int mHangCount = 0;
|
||||
private float mPower = 0.0f;
|
||||
private boolean mSquelch;
|
||||
private float mSquelchThreshold;
|
||||
private double mIntervalPower = 0.0;
|
||||
private double mIntervalPowerSquared = 0.0;
|
||||
private int mIntervalSampleCount = 0;
|
||||
private int mIntervalBlockCount = 0;
|
||||
private boolean mFirstIntervalComplete = false;
|
||||
private final float[] mNoiseHistory = new float[NOISE_WINDOW_INTERVALS];
|
||||
private int mNoiseHistoryPointer = 0;
|
||||
private int mNoiseHistoryCount = 0;
|
||||
private int mCarrierIntervalCount = 0;
|
||||
private int mVoiceIntervalCount = 0;
|
||||
private boolean mCarrierTimedOut = false;
|
||||
private boolean mCarrierDetected = false;
|
||||
private Float mNoiseFloorBeforeCarrier;
|
||||
private final VoiceModulationDetector mVoiceModulationDetector;
|
||||
private Float mNoiseFloor;
|
||||
private float mSquelchBuffer;
|
||||
private Float mLowestNoiseFloor;
|
||||
private float mUserThreshold;
|
||||
private boolean mSquelch = true;
|
||||
private float mSquelchThreshold;
|
||||
private boolean mSquelchChanged = false;
|
||||
private int mPowerLevelBroadcastCount = 0;
|
||||
private int mPowerLevelBroadcastThreshold;
|
||||
private boolean mAutoTrackNoiseFloor;
|
||||
private long mLastAutoAdjustTimestamp;
|
||||
private Listener<SourceEvent> mSourceEventListener;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
* Testing against a 12.5 kHz analog FM modulated signal, the following parameters provided a
|
||||
* good responsiveness. A threshold of -80.0 dB seemed to trigger significant flapping during un-squelching.
|
||||
* - alpha: 0.0001
|
||||
* - threshold: 78.0 dB
|
||||
*
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* @param autoTrack the noise floor when squelched and periodically readjust the squelch threshold.
|
||||
* @param bandwidth of the channel in hertz
|
||||
*/
|
||||
public AdaptiveSquelch(float alpha, float squelchThreshold, boolean autoTrack)
|
||||
public AdaptiveSquelch(float squelchThreshold, boolean autoTrack, double bandwidth)
|
||||
{
|
||||
mFilter = new SinglePoleIirFilter(alpha);
|
||||
mVoiceModulationDetector = new VoiceModulationDetector(DEFAULT_SAMPLE_RATE, bandwidth,
|
||||
(int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS) * POWER_INTERVAL_BLOCKS);
|
||||
setSquelchThreshold(squelchThreshold);
|
||||
mAutoTrackNoiseFloor = autoTrack;
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
mPowerLevelBroadcastThreshold = 25000; //Based on a default sample rate of 50 kHz, so 2x/second
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sample rate to effect the frequency of power level notifications where the notifications are
|
||||
* sent twice a second.
|
||||
* Sets the sample rate to size the power measurement blocks.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mPowerLevelBroadcastThreshold = sampleRate / 2;
|
||||
mBlockSize = Math.max(1, (int)Math.round(sampleRate * BLOCK_SECONDS));
|
||||
mVoiceModulationDetector.configure(sampleRate, mBlockSize * POWER_INTERVAL_BLOCKS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,32 +172,36 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold
|
||||
* Sets the squelch threshold. With auto-track enabled, the threshold is treated as a margin over the current noise
|
||||
* floor, subject to the minimum squelch margin. The margin is stored relative to the lowest observed noise floor so
|
||||
* that a change made while a timed out carrier has raised the noise floor applies once that carrier ends.
|
||||
* @param squelchThreshold in decibels
|
||||
*/
|
||||
public void setSquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
float squelchThresholdDb = fromDb(squelchThreshold);
|
||||
setSquelchThreshold(squelchThresholdDb, true);
|
||||
float threshold = fromDb(squelchThreshold);
|
||||
|
||||
if(mAutoTrackNoiseFloor && mNoiseFloor != null)
|
||||
{
|
||||
mUserThreshold = mLowestNoiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, threshold / mNoiseFloor);
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
else
|
||||
{
|
||||
mUserThreshold = threshold;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* Applies the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* and auto-track changes made by the stream processing thread.
|
||||
*
|
||||
* @param squelchThreshold value
|
||||
* @param resetNoiseFloor to reset the noise floor and squelch threshold
|
||||
*/
|
||||
private synchronized void setSquelchThreshold(float squelchThreshold, boolean resetNoiseFloor)
|
||||
private synchronized void applySquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
mSquelchThreshold = squelchThreshold;
|
||||
|
||||
if(resetNoiseFloor)
|
||||
{
|
||||
//Set noise floor to null so that both the noise floor and the squelch buffer get updated after first power reading
|
||||
mNoiseFloor = null;
|
||||
}
|
||||
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
}
|
||||
|
||||
@@ -183,59 +227,231 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a complex IQ sample and changes squelch state when the signal power is above or below the
|
||||
* threshold value.
|
||||
* Processes a complex IQ sample and updates the squelch state at the end of each power measurement block.
|
||||
* @param magnitude of a complex sample (inphase * inphase + quadrature * quadrature)
|
||||
*/
|
||||
public void process(float magnitude)
|
||||
{
|
||||
mPower = mFilter.filter(magnitude);
|
||||
mBlockAccumulator += magnitude;
|
||||
mBlockSampleCount++;
|
||||
mIntervalPower += magnitude;
|
||||
mIntervalPowerSquared += (double)magnitude * magnitude;
|
||||
mIntervalSampleCount++;
|
||||
|
||||
if(mSquelch && mPower >= mSquelchThreshold)
|
||||
//Voice detection is only needed for a carrier, so that channels with only noise don't incur this cost.
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
setSquelch(false);
|
||||
}
|
||||
else if(!mSquelch && mPower < mSquelchThreshold)
|
||||
{
|
||||
setSquelch(true);
|
||||
mVoiceModulationDetector.receive((float)Math.sqrt(magnitude));
|
||||
}
|
||||
|
||||
mPowerLevelBroadcastCount++;
|
||||
if(mPowerLevelBroadcastCount % mPowerLevelBroadcastThreshold == 0)
|
||||
if(mBlockSampleCount >= mBlockSize)
|
||||
{
|
||||
mPowerLevelBroadcastCount = 0;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
adjustSquelchThreshold();
|
||||
}
|
||||
float power = mBlockAccumulator / mBlockSampleCount;
|
||||
mBlockAccumulator = 0.0f;
|
||||
mBlockSampleCount = 0;
|
||||
processBlock(power);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With the user specified squelch threshold, establishes a squelch buffer (delta) from the actual noise floor and
|
||||
* then automatically updates the squelch threshold to be the current noise floor plus this threshold value.
|
||||
*
|
||||
* Note: this method should only be invoked from a squelch (true) state.
|
||||
* Updates the squelch state from a block power measurement.
|
||||
* @param power of the block
|
||||
*/
|
||||
private void adjustSquelchThreshold()
|
||||
private void processBlock(float power)
|
||||
{
|
||||
if(mAutoTrackNoiseFloor)
|
||||
mCloseAccumulator += power - mCloseBlocks[mCloseBlockPointer];
|
||||
mCloseBlocks[mCloseBlockPointer] = power;
|
||||
mCloseBlockPointer = (mCloseBlockPointer + 1) % CLOSE_AVERAGE_BLOCKS;
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
if(mNoiseFloor == null)
|
||||
//With auto-track, the threshold isn't reliable until there's a noise floor measurement.
|
||||
boolean ready = mFirstIntervalComplete || !mAutoTrackNoiseFloor;
|
||||
mOpenCount = (ready && power >= mSquelchThreshold) ? mOpenCount + 1 : 0;
|
||||
|
||||
if(mOpenCount >= OPEN_BLOCKS)
|
||||
{
|
||||
//We set the squelch buffer just once, after the squelch threshold is set and noise floor is set to null
|
||||
mSquelchBuffer = mSquelchThreshold - mPower;
|
||||
mOpenCount = 0;
|
||||
mHangCount = 0;
|
||||
//Seed the close average so that it doesn't start with the blocks from before the signal arrived.
|
||||
Arrays.fill(mCloseBlocks, power);
|
||||
mCloseAccumulator = power * CLOSE_AVERAGE_BLOCKS;
|
||||
setSquelch(false);
|
||||
}
|
||||
}
|
||||
else if(mCloseAccumulator / CLOSE_AVERAGE_BLOCKS >= mSquelchThreshold * CLOSE_THRESHOLD_RATIO)
|
||||
{
|
||||
mHangCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
mHangCount++;
|
||||
|
||||
if(mHangCount >= HANG_BLOCKS)
|
||||
{
|
||||
setSquelch(true);
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts power blocks to provide periodic power level notifications and noise floor updates.
|
||||
*/
|
||||
private void updatePowerInterval()
|
||||
{
|
||||
mIntervalBlockCount++;
|
||||
|
||||
if(mIntervalBlockCount >= POWER_INTERVAL_BLOCKS)
|
||||
{
|
||||
double mean = mIntervalPower / mIntervalSampleCount;
|
||||
mPower = (float)mean;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mAutoTrackNoiseFloor && mean > 0.0)
|
||||
{
|
||||
updateNoiseFloor(mIntervalPowerSquared / mIntervalSampleCount / (mean * mean));
|
||||
}
|
||||
|
||||
mNoiseFloor = mPower;
|
||||
mFirstIntervalComplete = true;
|
||||
mIntervalPower = 0.0;
|
||||
mIntervalPowerSquared = 0.0;
|
||||
mIntervalSampleCount = 0;
|
||||
mIntervalBlockCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if((System.currentTimeMillis() - mLastAutoAdjustTimestamp) > AUTO_ADJUST_PERIOD_MS)
|
||||
{
|
||||
setSquelchThreshold(mNoiseFloor + mSquelchBuffer, false);
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
/**
|
||||
* Updates the noise floor history from the current interval power and updates the squelch threshold.
|
||||
* @param powerRatio mean(power^2) / mean(power)^2 for the interval
|
||||
*/
|
||||
private void updateNoiseFloor(double powerRatio)
|
||||
{
|
||||
boolean voice = mVoiceModulationDetector.analyze();
|
||||
mCarrierDetected = powerRatio < NOISE_POWER_RATIO;
|
||||
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
updateCarrier(voice);
|
||||
}
|
||||
else
|
||||
{
|
||||
resetCarrierTracking();
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mNoiseHistoryCount > 0)
|
||||
{
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the carrier timeout from an interval that doesn't look like noise. A carrier that lasts for the timeout
|
||||
* period is treated as the noise floor, unless it has voice modulation.
|
||||
* @param voice true if voice modulation was detected in this interval
|
||||
*/
|
||||
private void updateCarrier(boolean voice)
|
||||
{
|
||||
mCarrierIntervalCount++;
|
||||
|
||||
if(voice)
|
||||
{
|
||||
mVoiceIntervalCount++;
|
||||
}
|
||||
|
||||
boolean hasVoice = mVoiceIntervalCount >= MINIMUM_VOICE_INTERVALS;
|
||||
|
||||
if(mCarrierTimedOut && hasVoice)
|
||||
{
|
||||
restoreNoiseFloor();
|
||||
resetCarrierTracking();
|
||||
}
|
||||
else if(mCarrierTimedOut)
|
||||
{
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
else if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS && !hasVoice)
|
||||
{
|
||||
//Replace the history so that the carrier takes effect immediately.
|
||||
mCarrierTimedOut = true;
|
||||
mNoiseFloorBeforeCarrier = mNoiseFloor;
|
||||
mNoiseHistoryCount = 0;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS)
|
||||
{
|
||||
resetCarrierTracking();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the counts for the current carrier evaluation period.
|
||||
*/
|
||||
private void resetCarrierTracking()
|
||||
{
|
||||
mCarrierIntervalCount = 0;
|
||||
mVoiceIntervalCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a timed out carrier from the noise floor, restoring the noise floor from before the carrier. If there's
|
||||
* no noise floor from before the carrier, the user threshold is restored until the noise floor can be measured.
|
||||
*/
|
||||
private void restoreNoiseFloor()
|
||||
{
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseHistoryCount = 0;
|
||||
|
||||
if(mNoiseFloorBeforeCarrier != null)
|
||||
{
|
||||
addNoisePower(mNoiseFloorBeforeCarrier);
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
mNoiseFloor = null;
|
||||
mLowestNoiseFloor = null;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the power to the noise history.
|
||||
* @param power of the interval
|
||||
*/
|
||||
private void addNoisePower(float power)
|
||||
{
|
||||
mNoiseHistory[mNoiseHistoryPointer] = power;
|
||||
mNoiseHistoryPointer = (mNoiseHistoryPointer + 1) % NOISE_WINDOW_INTERVALS;
|
||||
mNoiseHistoryCount = Math.min(mNoiseHistoryCount + 1, NOISE_WINDOW_INTERVALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold to the margin over the current noise floor, where the margin is the larger of the
|
||||
* minimum margin and the amount that the user threshold exceeds the lowest observed noise floor.
|
||||
*/
|
||||
private void updateTrackedThreshold()
|
||||
{
|
||||
float noiseFloor = Float.MAX_VALUE;
|
||||
|
||||
for(int x = 0; x < mNoiseHistoryCount; x++)
|
||||
{
|
||||
int index = (mNoiseHistoryPointer - 1 - x + NOISE_WINDOW_INTERVALS) % NOISE_WINDOW_INTERVALS;
|
||||
noiseFloor = Math.min(noiseFloor, mNoiseHistory[index]);
|
||||
}
|
||||
|
||||
mNoiseFloor = noiseFloor;
|
||||
mLowestNoiseFloor = (mLowestNoiseFloor == null) ? noiseFloor : Math.min(mLowestNoiseFloor, noiseFloor);
|
||||
float threshold = noiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, mUserThreshold / mLowestNoiseFloor);
|
||||
|
||||
if(threshold != mSquelchThreshold)
|
||||
{
|
||||
applySquelchThreshold(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +471,15 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
return !mSquelch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if audio should be passed. This is false during the hang period, while the signal is below the close
|
||||
* threshold but the squelch is still open.
|
||||
*/
|
||||
public boolean isAudioEnabled()
|
||||
{
|
||||
return !mSquelch && mHangCount == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current power level
|
||||
* @return current power level in dB
|
||||
@@ -309,6 +534,7 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
setSquelchThreshold(sourceEvent.getValue().floatValue());
|
||||
broadcast(SourceEvent.squelchUserThreshold(toDb(mUserThreshold)));
|
||||
break;
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
|
||||
Reference in New Issue
Block a user