mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
551 lines
19 KiB
Java
551 lines
19 KiB
Java
/*
|
|
* *****************************************************************************
|
|
* Copyright (C) 2014-2023 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.dsp.squelch;
|
|
|
|
import io.github.dsheirer.sample.Listener;
|
|
import io.github.dsheirer.source.SourceEvent;
|
|
import java.util.Arrays;
|
|
import org.apache.commons.math3.util.FastMath;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
/**
|
|
* 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 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 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 mLowestNoiseFloor;
|
|
private float mUserThreshold;
|
|
private boolean mSquelch = true;
|
|
private float mSquelchThreshold;
|
|
private boolean mSquelchChanged = false;
|
|
private boolean mAutoTrackNoiseFloor;
|
|
private Listener<SourceEvent> mSourceEventListener;
|
|
|
|
/**
|
|
* Constructs an instance
|
|
*
|
|
* @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 squelchThreshold, boolean autoTrack, double bandwidth)
|
|
{
|
|
mVoiceModulationDetector = new VoiceModulationDetector(DEFAULT_SAMPLE_RATE, bandwidth,
|
|
(int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS) * POWER_INTERVAL_BLOCKS);
|
|
setSquelchThreshold(squelchThreshold);
|
|
mAutoTrackNoiseFloor = autoTrack;
|
|
}
|
|
|
|
/**
|
|
* Sets the sample rate to size the power measurement blocks.
|
|
* @param sampleRate in hertz
|
|
*/
|
|
public void setSampleRate(int sampleRate)
|
|
{
|
|
mBlockSize = Math.max(1, (int)Math.round(sampleRate * BLOCK_SECONDS));
|
|
mVoiceModulationDetector.configure(sampleRate, mBlockSize * POWER_INTERVAL_BLOCKS);
|
|
}
|
|
|
|
/**
|
|
* Squelch threshold value
|
|
* @return value in decibels
|
|
*/
|
|
public float getSquelchThreshold()
|
|
{
|
|
return toDb(mSquelchThreshold);
|
|
}
|
|
|
|
/**
|
|
* Formats the value as decibels
|
|
* @param value to format
|
|
* @return formatted value.
|
|
*/
|
|
private float toDb(float value)
|
|
{
|
|
return 10.0f * (float)FastMath.log10(value);
|
|
}
|
|
|
|
/**
|
|
* Converts from a decibel value
|
|
* @param valueDb in decibels
|
|
* @return not decibels
|
|
*/
|
|
private float fromDb(float valueDb)
|
|
{
|
|
return (float)FastMath.pow(10.0, valueDb / 10.0);
|
|
}
|
|
|
|
/**
|
|
* Enables or disables the squelch auto-tracking feature where the squelch threshold is auto-adjusted based on the
|
|
* observed channel power when the signal is not present (ie squelch state).
|
|
* @param autoTrack true to turn on auto-track
|
|
*/
|
|
public void setSquelchAutoTrack(boolean autoTrack)
|
|
{
|
|
mAutoTrackNoiseFloor = autoTrack;
|
|
broadcast(SourceEvent.squelchAutoTrack(getSquelchAutoTrack()));
|
|
|
|
}
|
|
|
|
/**
|
|
* Current squelch auto-track setting.
|
|
* @return true if auto-track is enabled.
|
|
*/
|
|
public boolean getSquelchAutoTrack()
|
|
{
|
|
return mAutoTrackNoiseFloor;
|
|
}
|
|
|
|
/**
|
|
* 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 threshold = fromDb(squelchThreshold);
|
|
|
|
if(mAutoTrackNoiseFloor && mNoiseFloor != null)
|
|
{
|
|
mUserThreshold = mLowestNoiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, threshold / mNoiseFloor);
|
|
updateTrackedThreshold();
|
|
}
|
|
else
|
|
{
|
|
mUserThreshold = threshold;
|
|
applySquelchThreshold(mUserThreshold);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
private synchronized void applySquelchThreshold(float squelchThreshold)
|
|
{
|
|
mSquelchThreshold = squelchThreshold;
|
|
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
|
}
|
|
|
|
/**
|
|
* Sets the squelch state
|
|
* @param squelch true to squelch and false to unsquelch
|
|
*/
|
|
private void setSquelch(boolean squelch)
|
|
{
|
|
mSquelch = squelch;
|
|
mSquelchChanged = true;
|
|
}
|
|
|
|
/**
|
|
* Processes a complex IQ sample and changes squelch state when the signal power is above or below the
|
|
* threshold value.
|
|
* @param inphase complex sample component
|
|
* @param quadrature complex sample component
|
|
*/
|
|
public void process(float inphase, float quadrature)
|
|
{
|
|
process(inphase * inphase + quadrature * quadrature);
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
{
|
|
mBlockAccumulator += magnitude;
|
|
mBlockSampleCount++;
|
|
mIntervalPower += magnitude;
|
|
mIntervalPowerSquared += (double)magnitude * magnitude;
|
|
mIntervalSampleCount++;
|
|
|
|
//Voice detection is only needed for a carrier, so that channels with only noise don't incur this cost.
|
|
if(mCarrierDetected)
|
|
{
|
|
mVoiceModulationDetector.receive((float)Math.sqrt(magnitude));
|
|
}
|
|
|
|
if(mBlockSampleCount >= mBlockSize)
|
|
{
|
|
float power = mBlockAccumulator / mBlockSampleCount;
|
|
mBlockAccumulator = 0.0f;
|
|
mBlockSampleCount = 0;
|
|
processBlock(power);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the squelch state from a block power measurement.
|
|
* @param power of the block
|
|
*/
|
|
private void processBlock(float power)
|
|
{
|
|
mCloseAccumulator += power - mCloseBlocks[mCloseBlockPointer];
|
|
mCloseBlocks[mCloseBlockPointer] = power;
|
|
mCloseBlockPointer = (mCloseBlockPointer + 1) % CLOSE_AVERAGE_BLOCKS;
|
|
|
|
if(mSquelch)
|
|
{
|
|
//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)
|
|
{
|
|
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));
|
|
}
|
|
|
|
mFirstIntervalComplete = true;
|
|
mIntervalPower = 0.0;
|
|
mIntervalPowerSquared = 0.0;
|
|
mIntervalSampleCount = 0;
|
|
mIntervalBlockCount = 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Indicates if the current state is muted
|
|
*/
|
|
public boolean isMuted()
|
|
{
|
|
return mSquelch;
|
|
}
|
|
|
|
/**
|
|
* Indicates if the current state is unmuted
|
|
*/
|
|
public boolean isUnmuted()
|
|
{
|
|
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
|
|
*/
|
|
public float getPower()
|
|
{
|
|
return (float)(10.0 * Math.log10(mPower));
|
|
}
|
|
|
|
/**
|
|
* Indicates if the squelch state has changed (muted > unmuted, or vice-versa)
|
|
*/
|
|
public boolean isSquelchChanged()
|
|
{
|
|
return mSquelchChanged;
|
|
}
|
|
|
|
/**
|
|
* Sets or resets the squelch changed flag
|
|
*/
|
|
public void setSquelchChanged(boolean changed)
|
|
{
|
|
mSquelchChanged = changed;
|
|
}
|
|
|
|
/**
|
|
* Registers the listener to receive power level notifications and squelch threshold requests
|
|
*/
|
|
public void setSourceEventListener(Listener<SourceEvent> listener)
|
|
{
|
|
mSourceEventListener = listener;
|
|
}
|
|
|
|
/**
|
|
* Broadcasts the source event to an optional register listener
|
|
*/
|
|
private void broadcast(SourceEvent event)
|
|
{
|
|
if(mSourceEventListener != null)
|
|
{
|
|
mSourceEventListener.receive(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Primary method to receive requests for squelch threshold change
|
|
*/
|
|
@Override
|
|
public void receive(SourceEvent sourceEvent)
|
|
{
|
|
switch(sourceEvent.getEvent())
|
|
{
|
|
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()));
|
|
break;
|
|
case REQUEST_CHANGE_SQUELCH_AUTO_TRACK:
|
|
setSquelchAutoTrack(sourceEvent.getValue().intValue() == 1 ? true : false);
|
|
break;
|
|
case REQUEST_CURRENT_SQUELCH_AUTO_TRACK:
|
|
broadcast(SourceEvent.squelchAutoTrack(getSquelchAutoTrack()));
|
|
break;
|
|
}
|
|
}
|
|
}
|