#1310 NBFM decoder uses new Noise Squelch and includes new noise squelch view and control. (#2264)

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2025-08-02 08:34:32 -04:00
committed by GitHub
parent 912925ee7d
commit b2be38b239
32 changed files with 2999 additions and 858 deletions

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -58,11 +58,11 @@ public class FilterViewer extends Application
*/
private float[] getFilter()
{
int filterLength = 47;
WindowType windowType = WindowType.HAMMING;
int filterLength = 31;
WindowType windowType = WindowType.BLACKMAN_HARRIS_7;
// float[] taps = FilterFactory.getHalfBand(filterLength, windowType);
float[] taps = FilterFactory.getRootRaisedCosine(50000.0 / 4800.0, 20, 0.2f);
float[] taps = FilterFactory.getHighPass(8000, 3000, filterLength, windowType);
// float[] taps = FilterFactory.getRootRaisedCosine(50000.0 / 4800.0, 20, 0.2f);
// taps = HilbertTransform.HALF_BAND_FILTER_47_TAP;

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -19,10 +19,6 @@
package io.github.dsheirer.dsp.fm;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,63 +28,22 @@ import org.slf4j.LoggerFactory;
public abstract class VectorFMDemodulator implements IDemodulator
{
private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator.class);
protected static final float ZERO = 0.0f;
private static final int BUFFER_OVERLAP = 1;
//Initialize buffers to be non-null and resize once the first buffer arrives
private float[] mIBuffer = new float[1];
private float[] mQBuffer = new float[1];
private ScalarFMDemodulator mScalar;
public VectorFMDemodulator()
{
}
/**
* Vector species as specified by the implementing sub-class.
* Access the scalar implementation when the sample buffer size is not a multiple of the vector size.
*/
protected abstract VectorSpecies<Float> getVectorSpecies();
@Override public float[] demodulate(float[] i, float[] q)
protected ScalarFMDemodulator getScalarImplementation()
{
// VectorSpecies<Float> species= getVectorSpecies();
VectorUtilities.checkComplexArrayLength(i, q, getVectorSpecies());
if(mIBuffer.length != (i.length + BUFFER_OVERLAP))
if(mScalar == null)
{
mIBuffer = new float[i.length + BUFFER_OVERLAP];
mQBuffer = new float[q.length + BUFFER_OVERLAP];
mScalar = new ScalarFMDemodulator();
}
//Copy last sample to beginning of buffer arrays
mIBuffer[0] = mIBuffer[mIBuffer.length - 1];
mQBuffer[0] = mQBuffer[mQBuffer.length - 1];
//Copy new samples to end of buffer arrays
System.arraycopy(i, 0, mIBuffer, 1, i.length);
System.arraycopy(q, 0, mQBuffer, 1, q.length);
float[] demodulated = new float[i.length];
FloatVector currentI, currentQ, previousI, previousQ, demod, demodI, demodQ;
for(int bufferPointer = 0; bufferPointer < mIBuffer.length - 1; bufferPointer += getVectorSpecies().length())
{
previousI = FloatVector.fromArray(getVectorSpecies(), mIBuffer, bufferPointer);
previousQ = FloatVector.fromArray(getVectorSpecies(), mQBuffer, bufferPointer);
currentI = FloatVector.fromArray(getVectorSpecies(), mIBuffer, bufferPointer + 1);
currentQ = FloatVector.fromArray(getVectorSpecies(), mQBuffer, bufferPointer + 1);
demodI = currentI.mul(previousI).sub(currentQ.mul(previousQ.neg()));
demodQ = currentQ.mul(previousI).add(currentI.mul(previousQ.neg()));
//Replace any zero-values in the I vector by adding Float.MIN_VALUE to side-step divide by zero errors
demodI = demodI.add(Float.MIN_VALUE, demodQ.eq(ZERO));
demod = demodQ.div(demodI).lanewise(VectorOperators.ATAN);
demod.intoArray(demodulated, bufferPointer);
}
return demodulated;
return mScalar;
}
}

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.fm;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
@@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory;
/**
* FM demodulator that uses JDK 17+ SIMD vector intrinsics
*/
public class VectorFMDemodulator128 implements IDemodulator
public class VectorFMDemodulator128 extends VectorFMDemodulator
{
private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator128.class);
protected static final float ZERO = 0.0f;
@@ -44,9 +43,14 @@ public class VectorFMDemodulator128 implements IDemodulator
{
}
@Override public float[] demodulate(float[] i, float[] q)
@Override
public float[] demodulate(float[] i, float[] q)
{
VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES);
//Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation.
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i, q);
}
if(mIBuffer.length != (i.length + BUFFER_OVERLAP))
{

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.fm;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
@@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory;
/**
* FM demodulator that uses JDK 17+ SIMD vector intrinsics
*/
public class VectorFMDemodulator256 implements IDemodulator
public class VectorFMDemodulator256 extends VectorFMDemodulator
{
private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator256.class);
protected static final float ZERO = 0.0f;
@@ -44,9 +43,14 @@ public class VectorFMDemodulator256 implements IDemodulator
{
}
@Override public float[] demodulate(float[] i, float[] q)
@Override
public float[] demodulate(float[] i, float[] q)
{
VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES);
//Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation.
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
if(mIBuffer.length != (i.length + BUFFER_OVERLAP))
{

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.fm;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
@@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory;
/**
* FM demodulator that uses JDK 17+ SIMD vector intrinsics
*/
public class VectorFMDemodulator512 implements IDemodulator
public class VectorFMDemodulator512 extends VectorFMDemodulator
{
private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator512.class);
protected static final float ZERO = 0.0f;
@@ -44,9 +43,14 @@ public class VectorFMDemodulator512 implements IDemodulator
{
}
@Override public float[] demodulate(float[] i, float[] q)
@Override
public float[] demodulate(float[] i, float[] q)
{
VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES);
//Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation.
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
if(mIBuffer.length != (i.length + BUFFER_OVERLAP))
{

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2023 Dennis Sheirer
* Copyright (C) 2014-2025 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
@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.fm;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorOperators;
import jdk.incubator.vector.VectorSpecies;
@@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory;
/**
* FM demodulator that uses JDK 17+ SIMD vector intrinsics
*/
public class VectorFMDemodulator64 implements IDemodulator
public class VectorFMDemodulator64 extends VectorFMDemodulator
{
private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator64.class);
protected static final float ZERO = 0.0f;
@@ -44,9 +43,14 @@ public class VectorFMDemodulator64 implements IDemodulator
{
}
@Override public float[] demodulate(float[] i, float[] q)
@Override
public float[] demodulate(float[] i, float[] q)
{
VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES);
//Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation.
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
if(mIBuffer.length != (i.length + BUFFER_OVERLAP))
{

View File

@@ -37,6 +37,7 @@ public abstract class DifferentialDemodulator implements IDemodulator
protected int mBufferOverlap;
protected int mInterpolationOffset;
protected final Interpolator mInterpolator = InterpolatorFactory.getInterpolator();
private DifferentialDemodulatorScalar mScalar;
/**
* Constructor
@@ -59,6 +60,20 @@ public abstract class DifferentialDemodulator implements IDemodulator
}
}
/**
* Scalar implementation for fallback when the sample buffer size is not compatible with the vector implementation.
* @return scalar implementation lazy instantiated when needed.
*/
protected DifferentialDemodulatorScalar getScalarImplementation()
{
if(mScalar == null)
{
mScalar = new DifferentialDemodulatorScalar(mSampleRate, mSymbolRate);
}
return mScalar;
}
/**
* Calculated samples per symbol.
* @return samples per symbol.

View File

@@ -48,6 +48,11 @@ public class DifferentialDemodulatorVector128 extends DifferentialDemodulator
@Override
public float[] demodulate(float[] i, float[] q)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
int sampleLength = i.length;
int bufferOverlap = mBufferOverlap;
float mu = mMu;
@@ -60,10 +65,6 @@ public class DifferentialDemodulatorVector128 extends DifferentialDemodulator
int requiredBufferLength = sampleLength + bufferOverlap;
if(mIBuffer.length != requiredBufferLength)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length());
}
mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength);
mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength);
}

View File

@@ -45,6 +45,11 @@ public class DifferentialDemodulatorVector256 extends DifferentialDemodulator
@Override
public float[] demodulate(float[] i, float[] q)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
int sampleLength = i.length;
int bufferOverlap = mBufferOverlap;
float mu = mMu;
@@ -57,10 +62,6 @@ public class DifferentialDemodulatorVector256 extends DifferentialDemodulator
int requiredBufferLength = sampleLength + bufferOverlap;
if(mIBuffer.length != requiredBufferLength)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length());
}
mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength);
mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength);
}

View File

@@ -47,6 +47,11 @@ public class DifferentialDemodulatorVector512 extends DifferentialDemodulator
@Override
public float[] demodulate(float[] i, float[] q)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
return getScalarImplementation().demodulate(i,q);
}
int sampleLength = i.length;
int bufferOverlap = mBufferOverlap;
float mu = mMu;
@@ -59,10 +64,6 @@ public class DifferentialDemodulatorVector512 extends DifferentialDemodulator
int requiredBufferLength = sampleLength + bufferOverlap;
if(mIBuffer.length != requiredBufferLength)
{
if(i.length % VECTOR_SPECIES.length() != 0)
{
throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length());
}
mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength);
mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength);
}

View File

@@ -0,0 +1,54 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 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;
/**
* Interface for a decoder to expose access to noise squelch controls and status registration endpoint(s).
*/
public interface INoiseSquelchController
{
/**
* Registers the listener to receive noise squelch states every 10 milliseconds for UI display.
* @param listener to receive states or pass null to de-register a listener.
*/
void setNoiseSquelchStateListener(Listener<NoiseSquelchState> listener);
/**
* Sets the open and close noise thresholds in range 0.0 to 1.0, recommend: 0.1 open and 0.2 close
* @param open for the open noise variance calculation.
* @param close for the close noise variance calculation.
*/
void setNoiseThreshold(float open, float close);
/**
* Sets the hysteresis threshold in units of 10 milliseconds
* @param open in range 1-10, recommend: 4
* @param close in range 1-10, recommend: 6
*/
void setHysteresisThreshold(int open, int close);
/**
* Manual override of the squelch control to set an always un-squelch state (true) or automatic control (false).
* @param override (true) or (false) to turn off squelch override.
*/
void setSquelchOverride(boolean override);
}

View File

@@ -0,0 +1,529 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 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.audio.squelch.SquelchState;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.sample.Listener;
import java.util.Arrays;
/**
* Squelch processor for unfiltered, demodulated audio that operators by high-pass filtering the audio to remove below
* 3,000 Hertz and processes 10-millisecond audio buffers to calculate the noise variance across the samples. This
* variance is compared to a noise threshold and uses time hysteresis in the form of consecutive buffers to toggle the
* squelch open or closed. Uses a delay buffer equal in length to the hysteresis plus two which enables locating the
* precise transition point of squelch/un-squelch and passing the audio of the detected signal while blocking the audio
* when there is no signal detected. Register an audio listener to receive squelch controlled audio and a squelch state
* listener to receive squelch toggle indications.
*/
public class NoiseSquelch implements INoiseSquelchController
{
public static final int DEFAULT_HYSTERESIS_OPEN_THRESHOLD = 4;
public static final int DEFAULT_HYSTERESIS_CLOSE_THRESHOLD = 6;
public static final int MINIMUM_HYSTERESIS_THRESHOLD = 1;
public static final int MAXIMUM_HYSTERESIS_THRESHOLD = 10;
public static final int VARIANCE_CALCULATION_WINDOW_MILLISECONDS = 10;
public static final float DEFAULT_NOISE_OPEN_THRESHOLD = 0.1f;
public static final float DEFAULT_NOISE_CLOSE_THRESHOLD = 0.19f;
public static final float MINIMUM_NOISE_THRESHOLD = 0.1f;
public static final float MAXIMUM_NOISE_THRESHOLD = 0.5f;
private float[] mFilteredBuffer = new float[0];
private float[] mAudioBuffer = new float[0];
private float mMeanAccumulator;
private float mNoiseOpenThreshold = DEFAULT_NOISE_OPEN_THRESHOLD;
private float mNoiseCloseThreshold = DEFAULT_NOISE_CLOSE_THRESHOLD;
private boolean mSquelch = true;
private boolean mSquelchOverride = false;
private int mMeanAccumulatorPointer;
private int mVarianceWindowSize;
private int mHysteresisOpenThreshold = DEFAULT_HYSTERESIS_OPEN_THRESHOLD;
private int mHysteresisCloseThreshold = DEFAULT_HYSTERESIS_CLOSE_THRESHOLD;
private int mHysteresisCount = 0;
private int mSquelchStateBroadcastCounter = 0;
private int mSquelchOpenIndex = 0;
private int mAudioBufferFilterDelay;
private IRealFilter mHighPassFilter;
private Listener<float[]> mAudioListener;
private Listener<SquelchState> mSquelchStateListener;
private Listener<NoiseSquelchState> mNoiseSquelchStateListener;
/**
* Constructs an instance
* @param noiseOpen in range 0.0 to 1.0 - recommend 0.1 to 0.2 (units of demodulated, high-pass filtered audio
* sample variance where the high-pass filtered audio samples range -1.0 to 1.0 and maximum variance is half of that
* range: 1.0)
* @param hysteresisOpen in range 1 to 10 controls the squelch toggle hysteresis - recommend 4 (units of 10 ms)
*/
public NoiseSquelch(float noiseOpen, float noiseClose, int hysteresisOpen, int hysteresisClose)
{
setNoiseThreshold(noiseOpen, noiseClose);
setHysteresisThreshold(hysteresisOpen, hysteresisClose);
}
/**
* Indicates if the current state is squelched.
* @return true if squelched and false if not squelched.
*/
public boolean isSquelched()
{
return mSquelch;
}
/**
* Sets the manual squelch override.
* @param override (true) or (false) to turn off manual squelch override.
*/
@Override
public void setSquelchOverride(boolean override)
{
mSquelchOverride = override;
if(mSquelchOverride)
{
broadcast(SquelchState.UNSQUELCH);
}
}
/**
* Sets the noise thresholds
* @param open in range 0.0-1.0 and less than or equal to close, recommend 0.1
* @param close in range 0.0-1.0 and greater than or equal to open, recommend 0.2
*/
public void setNoiseThreshold(float open, float close)
{
if(open < MINIMUM_NOISE_THRESHOLD || close > MAXIMUM_NOISE_THRESHOLD || open > close)
{
throw new IllegalArgumentException("Noise thresholds open/close [" + open + "/" + close + "] - open must be less than or equal to close and both in range " + MINIMUM_NOISE_THRESHOLD + "-" + MAXIMUM_NOISE_THRESHOLD);
}
mNoiseOpenThreshold = open;
mNoiseCloseThreshold = close;
}
/**
* Updates the hysteresis thresholds
* @param open in range 1 to 10 and less than or equal to close, recommend 4.
* @param close in range 1 to 10 and greater than or equal to open, recommend 6.
*/
public void setHysteresisThreshold(int open, int close)
{
if(open < MINIMUM_HYSTERESIS_THRESHOLD || close > MAXIMUM_HYSTERESIS_THRESHOLD || open > close)
{
throw new IllegalArgumentException("Hysteresis threshold open [" + open + "] must be less than or equal to close [" +
close + "] and both in range " + MINIMUM_HYSTERESIS_THRESHOLD + "-" + MAXIMUM_HYSTERESIS_THRESHOLD);
}
mHysteresisOpenThreshold = open;
mHysteresisCloseThreshold = close;
mHysteresisCount = Math.min(mHysteresisCloseThreshold, mHysteresisCount);
}
/**
* Registers the listener to receive noise squelch state updates every 100 milliseconds.
* @param listener to register, or de-register by passing in a null.
*/
public void setNoiseSquelchStateListener(Listener<NoiseSquelchState> listener)
{
mNoiseSquelchStateListener = listener;
}
/**
* Registers the listener to receive squelch state indications. Squelch state events will be sent to the listener
* in concert with processed audio buffers. An un-squelch event is followed by one or more audio buffers and then a
* squelch event.
* @param listener to register or de-register by passing in a null.
*/
public void setSquelchStateListener(Listener<SquelchState> listener)
{
mSquelchStateListener = listener;
}
/**
* Registers the listener to receive processed audio buffers in between un-squelch/squelch events.
* @param listener to register or de-register by passing in a null.
*/
public void setAudioListener(Listener<float[]> listener)
{
mAudioListener = listener;
}
/**
* Broadcasts the current noise squelch state to an optionally registered listener. This can be use used in the
* user interface to display the state of the channel and noise squelch activity.
* @param noise variance from the current 10-millisecond audio buffer.
*/
private void broadcastNoiseSquelchState(float noise)
{
if(mNoiseSquelchStateListener != null)
{
mNoiseSquelchStateListener.receive(new NoiseSquelchState(mSquelch, mSquelchOverride, noise,
mNoiseOpenThreshold, mNoiseCloseThreshold, mHysteresisCount, mHysteresisOpenThreshold,
mHysteresisCloseThreshold));
}
}
/**
* Broadcasts from the unfiltered audio buffer to an optionally registered audio listener. Note: the start and end
* indices are pointers to the filtered audio buffer, and we dispatch from the unfiltered audio buffer which is offset
* by half the length of the high-pass audio filter coefficients. This method adjusts the index arguments by this
* offset value to align the unfiltered audio with the high-pass filtered audio detection boundaries.
*
* @param start index of the filtered audio buffer
* @param end index of the filtered audio buffer
*/
private void broadcast(int start, int end)
{
if(mAudioListener != null)
{
mAudioListener.receive(Arrays.copyOfRange(mAudioBuffer, start, end));
}
}
/**
* Broadcasts the squelch state change to an optionally registered squelch state listener.
* @param squelchState to dispatch.
*/
private void broadcast(SquelchState squelchState)
{
//Override squelch state when we're in manual override
if(squelchState == SquelchState.SQUELCH && mSquelchOverride)
{
squelchState = SquelchState.UNSQUELCH;
}
if(mSquelchStateListener != null)
{
mSquelchStateListener.receive(squelchState);
}
}
/**
* Sets the sample rate for the incoming demodulated audio sample stream.
* @param sampleRate of the sample stream.
*/
public void setSampleRate(double sampleRate)
{
float[] coefficients = FilterFactory.getHighPass((int)sampleRate, 3000, 31, WindowType.BLACKMAN_HARRIS_7);
mHighPassFilter = new RealFIRFilter(coefficients);
mVarianceWindowSize = (int)(sampleRate * (VARIANCE_CALCULATION_WINDOW_MILLISECONDS / 1000.0));
//Offset for the filter delay between the audio buffer and the filtered audio noise detection buffer.
mAudioBufferFilterDelay = (int)Math.ceil(coefficients.length / 2.0);
}
public void process(float[] samples)
{
float[] filtered = mHighPassFilter.filter(samples);
int requiredLength = filtered.length + (mVarianceWindowSize * (mHysteresisCloseThreshold + 2));
//Resize the delay buffers if the incoming sample buffer size changes.
if(mFilteredBuffer.length != requiredLength)
{
mFilteredBuffer = Arrays.copyOf(mFilteredBuffer, requiredLength);
//The audio buffer is longer to account for filter delay of the high pass filter and to align the indices
//across the filtered and audio buffers.
mAudioBuffer = Arrays.copyOf(mAudioBuffer, requiredLength + mAudioBufferFilterDelay);
}
//Shuffle the samples to the left in the delay buffers and copy in the new samples.
int length = mFilteredBuffer.length - filtered.length;
System.arraycopy(mFilteredBuffer, filtered.length, mFilteredBuffer, 0, length);
System.arraycopy(filtered, 0, mFilteredBuffer, length, filtered.length);
length = mAudioBuffer.length - samples.length;
System.arraycopy(mAudioBuffer, samples.length, mAudioBuffer, 0, length);
System.arraycopy(samples, 0, mAudioBuffer, length, samples.length);
int squelchCloseIndex;
//Adjust the squelch open index in sync with the left-shuffle of the audio samples.
mSquelchOpenIndex = Math.max(mSquelchOpenIndex - samples.length, 0);
//Process the newly added (filtered) samples in the delay buffer.
for(int x = mFilteredBuffer.length - filtered.length; x < mFilteredBuffer.length; x++)
{
mMeanAccumulator += mFilteredBuffer[x];
mMeanAccumulatorPointer++;
if(mMeanAccumulatorPointer == mVarianceWindowSize)
{
float mean = mMeanAccumulator / mVarianceWindowSize;
//Reset averaging variables
mMeanAccumulator = 0.0f;
mMeanAccumulatorPointer = 0;
//Calculate the variance
float varianceAccumulator1 = 0.0f, deviation, varianceAccumulator2 = 0.0f;
for(int i = x - mVarianceWindowSize + 1; i <= x; i++)
{
deviation = mFilteredBuffer[i] - mean;
varianceAccumulator1 += deviation * deviation;
varianceAccumulator2 += deviation;
}
//Formula from Apache Commons Math - Variance class.
float noiseVariance = (varianceAccumulator1 - (varianceAccumulator2 * varianceAccumulator2 / mVarianceWindowSize)) / mVarianceWindowSize;
boolean below = mSquelch ? (noiseVariance < mNoiseOpenThreshold) : (noiseVariance < mNoiseCloseThreshold);
mHysteresisCount += (below ? 1 : -1);
if(mSquelch && mHysteresisCount >= mHysteresisOpenThreshold)
{
mSquelch = false;
mHysteresisCount = mHysteresisOpenThreshold;
mSquelchOpenIndex = findTransition(x);
if(!mSquelchOverride)
{
broadcast(SquelchState.UNSQUELCH);
}
}
else if(!mSquelch && mHysteresisCount <= 0)
{
mSquelch = true;
mHysteresisCount = 0;
squelchCloseIndex = findTransition(x);
//Broadcast the partial audio segment ending at the transition point.
if(mSquelchOpenIndex < squelchCloseIndex)
{
//Only broadcast the truncated audio samples when we're not in squelch override
if(!mSquelchOverride)
{
broadcast(mSquelchOpenIndex, squelchCloseIndex);
}
broadcast(SquelchState.SQUELCH);
}
mSquelchOpenIndex = 0;
}
else if(mSquelch && !mSquelchOverride)
{
int start = x - (mVarianceWindowSize * mHysteresisOpenThreshold) + 1;
int end = start + mVarianceWindowSize;
//Make sure start doesn't go negative
start = Math.max(0, start);
Arrays.fill(mAudioBuffer, start, end, 0.0f);
}
mHysteresisCount = Math.min(mHysteresisCount, mHysteresisCloseThreshold);
mHysteresisCount = Math.max(mHysteresisCount, 0);
//Broadcast squelch state to an optionally registered listener (the channel tab in user interface)
mSquelchStateBroadcastCounter++;
if(mSquelchStateBroadcastCounter >= 5)
{
broadcastNoiseSquelchState(noiseVariance);
mSquelchStateBroadcastCounter -= 5;
}
}
}
if(mSquelchOverride)
{
broadcast(0, samples.length);
}
else if(!mSquelch && mSquelchOpenIndex < (samples.length))
{
mSquelchOpenIndex = Math.max(mSquelchOpenIndex, 0);
//Dispatch audio after processing the audio buffer if we end in an un-squelch state.
broadcast(mSquelchOpenIndex, samples.length);
mSquelchOpenIndex = 0;
}
}
/**
* Identifies the squelch transition point across a hysteresis window delayed by 2x buffer periods from the current
* processing index. This enables fine-grained squelch control without losing audio during the un-squelch period or
* allowing noise to leak into the squelch period at the end of the transmission. Uses a reduced 20-sample variance
* calculation window extended to the hysteresis time period to detect the transition point. Identifies the
* transition point within +/- 20 audio samples.
*
* @param current buffer processing index.
* @return buffer index of detected transition.
*/
private int findTransition(int current)
{
//Search window is 10-ms buffer size extended to hysteresis period, delayed by 2x buffer lengths.
int window = mSquelch ? mHysteresisCloseThreshold : mHysteresisOpenThreshold;
//Thresholds here may seem reversed but keep in mind that the mSquelch state has already been flipped and thus
// represents the state moving forward, and we need to detect the transition from the previous mSquelch state.
float varianceThreshold = mSquelch ? mNoiseCloseThreshold : mNoiseOpenThreshold;
int hysteresisThreshold = mSquelch ? mHysteresisCloseThreshold : mHysteresisOpenThreshold;
//Look back window start and end indices are sized according the previous mSquelch state
int start = current - (mVarianceWindowSize * (window + 2));
int end = start + (mVarianceWindowSize * window);
int hysteresisCount = 0;
int windowSize = mVarianceWindowSize / 5; //Decrease transition variance window to 1/5th of the normal variance window
int transitionPointer = 0;
for(int x = start; x < end; x += windowSize)
{
float mean = getMean(x, x + windowSize);
float variance = getVariance(x, x + windowSize, mean);
//Detect transition from from un-squelch to squelch
boolean exceedsThreshold = (mSquelch ? (variance > varianceThreshold) : (variance < varianceThreshold));
if(exceedsThreshold)
{
if(hysteresisCount == 0)
{
hysteresisCount++;
transitionPointer = x;
}
else
{
hysteresisCount++;
if(hysteresisCount >= hysteresisThreshold)
{
return transitionPointer;
}
}
}
else
{
hysteresisCount--;
hysteresisCount = Math.max(0, hysteresisCount);
}
}
return 0;
}
/**
* Calculate the mean/average of values between the start and end indices.
* @param start index
* @param end index
* @return mean value.
*/
private float getMean(int start, int end)
{
if(start < 0)
{
start = 0;
}
if(end > mFilteredBuffer.length)
{
end = mFilteredBuffer.length;
}
float accumulator = 0.0f;
for(int x = start; x < end; x++)
{
accumulator += mFilteredBuffer[x];
}
return accumulator / (end - start);
}
/**
* Calculates the variance of the values between start and end indices using the supplied mean value.
* @param start index
* @param end index
* @param mean of values between start and end, calculated previously
* @return variance.
*/
private float getVariance(int start, int end, float mean)
{
if(start < 0)
{
start = 0;
}
if(end > mFilteredBuffer.length)
{
end = mFilteredBuffer.length;
}
float varianceAccumulator1 = 0.0f, deviation, varianceAccumulator2 = 0.0f;
for(int i = start; i < end; i++)
{
deviation = mFilteredBuffer[i] - mean;
varianceAccumulator1 += deviation * deviation;
varianceAccumulator2 += deviation;
}
int sampleSize = end - start;
//Formula from Apache Commons Math - Variance class.
return (varianceAccumulator1 - (varianceAccumulator2 * varianceAccumulator2 / sampleSize)) / sampleSize;
}
public static void main(String[] args)
{
// Path directory = Paths.get("/media/denny/T9/NBFM Squelch Research"); //Linux
// Path file = directory.resolve("20250629_072704_453850000_Onondaga-County_Fire-EMS_OC-Fire-Disp_79_baseband.wav");
// Path file = directory.resolve("DMR_1_CAPPLUS.wav");
// Path file = directory.resolve("20250702_034109_145510000_R828D_V4_POLY_No_Antenna_Normal_Gain.wav");
// Path file = directory.resolve("20250702_034301_145510000_R828D_V4_POLY_No_Antenna_Max_Gain.wav");
// Path file = directory.resolve("20250702_034403_145510000_R828D_V4_POLY_No_Antenna_Min_Gain.wav");
// Path file = directory.resolve("20250702_034541_145510000_R828D_V4_HET_No_Antenna_Min_Gain.wav");
// Path file = directory.resolve("20250702_034635_145510000_R828D_V4_HET_No_Antenna_Max_Gain.wav");
// Path file = directory.resolve("20250702_034742_145510000_R828D_V4_HET_No_Antenna_Normal_Gain.wav");
// Path file = directory.resolve("20250703_043912_162500000_NOAA_25_khz_no_center_signal.wav");
// Path file = directory.resolve("20250703_043958_162550000_NOAA_25_khz_yes_center_signal.wav");
//
// DecodeConfigNBFM config = new DecodeConfigNBFM();
// config.setBandwidth(DecodeConfigAnalog.Bandwidth.BW_12_5);
// NBFMDecoder decoder = new NBFMDecoder(config);
//
// try(ComplexWaveSource source = new ComplexWaveSource(file.toFile(), false))
// {
// source.setListener(iNativeBuffer -> {
// Iterator<ComplexSamples> it = iNativeBuffer.iterator();
//
// while(it.hasNext())
// {
// ComplexSamples samples = it.next();
// decoder.receive(samples);
// }
// });
// source.start();
//
// decoder.getSourceEventListener().receive(SourceEvent.sampleRateChange(source.getSampleRate()));
//
// while(true)
// {
// source.next(2048, true);
// }
// }
// catch(IOException ioe)
// {
//
// ioe.printStackTrace();
// }
//
// System.out.println("Finished");
}
}

View File

@@ -0,0 +1,35 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 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;
/**
* Noise Squelch operating status record. Captures a snapshot in time of the squelch status.
* @param squelch state, on (true) or off (false)
* @param squelchOverride on (true) or off (false)
* @param noise - current noise variance
* @param noiseOpenThreshold for determining un-squelch state
* @param noiseCloseThreshold for determining squelch state
* @param hysteresis in units of 10-milliseconds
* @param hysteresisOpenThreshold for toggling the squelch state.
* @param hysteresisCloseThreshold for toggling the squelch state.
*/
public record NoiseSquelchState(boolean squelch, boolean squelchOverride, float noise, float noiseOpenThreshold,
float noiseCloseThreshold, int hysteresis, int hysteresisOpenThreshold,
int hysteresisCloseThreshold){}