#2301 am decoder enhancements. New squelch control and filter enhancements. (#2556)

Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
Denny Sheirer
2026-11-01 08:55:34 -05:00
committed by GitHub
parent 19d18b96b3
commit 8aadbe4706
33 changed files with 1886 additions and 396 deletions

View File

@@ -30,6 +30,16 @@ import javax.sound.sampled.AudioFormat;
*/
public abstract class AudioProvider
{
/**
* Converts the audio sample to a 16-bit PCM sample, limiting out of range values instead of allowing them to wrap.
* @param sample in range -1.0 to 1.0
* @return PCM sample
*/
protected static short toPcm(float sample)
{
return (short)(Math.clamp(sample, -1.0f, 1.0f) * Short.MAX_VALUE);
}
/**
* Audio format for this provider
*/

View File

@@ -77,7 +77,7 @@ public class AudioProviderMono extends AudioProvider
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
{
shortBuffer.put((short) (audio[x] * Short.MAX_VALUE));
shortBuffer.put(toPcm(audio[x]));
}
return buffer;

View File

@@ -108,7 +108,7 @@ public class AudioProviderMultiChannel extends AudioProvider
{
for(int channelNumber = 0; channelNumber < mAudioChannels.size(); channelNumber++)
{
shortBuffer.put((short)(samples[channelNumber][sampleCounter] * Short.MAX_VALUE));
shortBuffer.put(toPcm(samples[channelNumber][sampleCounter]));
}
}

View File

@@ -102,8 +102,8 @@ public class AudioProviderStereo extends AudioProvider
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
{
shortBuffer.put((short)(left[x] * Short.MAX_VALUE));
shortBuffer.put((short)(right[x] * Short.MAX_VALUE));
shortBuffer.put(toPcm(left[x]));
shortBuffer.put(toPcm(right[x]));
}
return buffer;

View File

@@ -0,0 +1,117 @@
/*
* *****************************************************************************
* 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.dsp.am;
/**
* Converts an AM envelope to audio by removing the carrier (DC) and scaling the audio by the carrier level.
*
* An AM signal's envelope is carrier * (1 + modulation), so using the carrier as the gain reference gives audio that
* follows the modulation depth of the transmission, independent of the received signal strength and without the
* pumping of an audio peak based AGC.
*
* The carrier estimate uses a cumulative mean at the start of a transmission to lock quickly and without a transient,
* and then transitions to a low-pass filter. The DC removal follows the carrier estimate closely. The gain reference
* rises with the carrier but falls slowly so that when the carrier drops, the remaining noise isn't amplified to full
* scale before the squelch closes.
*/
public class AmAudioNormalizer
{
private static final double CARRIER_CUTOFF_HERTZ = 10.0;
private static final double GAIN_DECAY_SECONDS = 2.0;
/**
* Gain reference limit, approximately -80 dBFS, that sets the maximum applied gain.
*/
private static final float MINIMUM_CARRIER_LEVEL = 1E-4f;
/**
* Audio amplitude produced by 100% modulation.
*/
private static final float FULL_MODULATION_AMPLITUDE = 0.75f;
private static final float MAXIMUM_AMPLITUDE = 0.95f;
private float mCarrierAlpha;
private float mGainDecayAlpha;
private int mSettlingSamples;
private int mSampleCount;
private float mCarrier;
private float mGainReference;
/**
* Constructs an instance
* @param sampleRate of the envelope samples in hertz
*/
public AmAudioNormalizer(double sampleRate)
{
setSampleRate(sampleRate);
}
/**
* Sets the sample rate and resets this normalizer.
* @param sampleRate of the envelope samples in hertz
*/
public void setSampleRate(double sampleRate)
{
mCarrierAlpha = (float)(1.0 - Math.exp(-2.0 * Math.PI * CARRIER_CUTOFF_HERTZ / sampleRate));
mGainDecayAlpha = (float)(1.0 - Math.exp(-1.0 / (GAIN_DECAY_SECONDS * sampleRate)));
mSettlingSamples = (int)Math.ceil(1.0 / mCarrierAlpha);
reset();
}
/**
* Resets this normalizer to prepare for a new transmission.
*/
public void reset()
{
mSampleCount = 0;
mCarrier = 0.0f;
mGainReference = 0.0f;
}
/**
* Converts the envelope sample to an audio sample.
* @param envelope sample, unscaled
* @return audio sample limited to the audio amplitude range.
*/
public float process(float envelope)
{
if(mSampleCount < mSettlingSamples)
{
mSampleCount++;
mCarrier += (envelope - mCarrier) / mSampleCount;
mGainReference = mCarrier;
}
else
{
mCarrier += (envelope - mCarrier) * mCarrierAlpha;
if(mCarrier > mGainReference)
{
mGainReference = mCarrier;
}
else
{
mGainReference += (mCarrier - mGainReference) * mGainDecayAlpha;
}
}
float audio = (envelope - mCarrier) / Math.max(mGainReference, MINIMUM_CARRIER_LEVEL) * FULL_MODULATION_AMPLITUDE;
return Math.clamp(audio, -MAXIMUM_AMPLITUDE, MAXIMUM_AMPLITUDE);
}
}

View File

@@ -51,8 +51,6 @@ public class AmDemodulatorFactory
return new VectorAMDemodulator256(gain);
case VECTOR_SIMD_512:
return new VectorAMDemodulator512(gain);
case VECTOR_SIMD_PREFERRED:
return new VectorAMDemodulator64(gain);
case SCALAR:
case UNCALIBRATED:
return new ScalarAMDemodulator(gain);

View File

@@ -17,13 +17,14 @@
* ****************************************************************************
*/
package io.github.dsheirer.dsp.fm;
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.fm.IDemodulator;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.SourceEvent;
/**
* Interface for FM demodulator that provides squelch control.
* Interface for AM demodulator that provides squelch control.
*/
public interface ISquelchingDemodulator extends IDemodulator
{

View File

@@ -19,7 +19,6 @@
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
@@ -32,32 +31,63 @@ import io.github.dsheirer.source.SourceEvent;
public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
{
private static final float ZERO = 0.0f;
private static final float ENVELOPE_GAIN = 1.0f;
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
private static final double FADE_SECONDS = 0.005;
/**
* Audio delay, longer than the worst case 25 ms for the squelch to detect loss of signal plus the fade duration, so
* that the audio fades out before the carrier drop reaches the audio normalizer, which would otherwise produce a
* thump at the end of a transmission.
*/
private static final double AUDIO_DELAY_SECONDS = 0.032;
private final IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
private final IAmDemodulator mAmDemodulator;
private final AdaptiveSquelch mAdaptiveSquelch;
private final AmAudioNormalizer mAudioNormalizer = new AmAudioNormalizer(DEFAULT_SAMPLE_RATE);
private float[] mDelayedEnvelope;
private boolean[] mDelayedAudioEnabled;
private int mDelayPointer = 0;
private float mFadeGain = 0.0f;
private float mFadeStep = (float)(1.0 / (DEFAULT_SAMPLE_RATE * FADE_SECONDS));
private boolean mSquelchChanged = false;
/**
* Constructs an instance
* @param gain to apply to the demodulated AM samples (e.g. 500.0f)
* @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 squelchAutoTrack to enable the squelch noise floor auto tracking feature.
* @param bandwidth of the channel in hertz
*/
public SquelchingAMDemodulator(float gain, float alpha, float squelchThreshold, boolean squelchAutoTrack)
public SquelchingAMDemodulator(float squelchThreshold, boolean squelchAutoTrack, double bandwidth)
{
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(gain);
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(ENVELOPE_GAIN);
mAdaptiveSquelch = new AdaptiveSquelch(squelchThreshold, squelchAutoTrack, bandwidth);
setAudioDelay(DEFAULT_SAMPLE_RATE);
}
/**
* Set or update the sample rate for the squelch to adjust the power level notification rate.
* Set or update the sample rate for the squelch to adjust the power level notification rate and for the audio
* normalizer timing.
* @param sampleRate in hertz
*/
public void setSampleRate(int sampleRate)
{
mAdaptiveSquelch.setSampleRate(sampleRate);
mAudioNormalizer.setSampleRate(sampleRate);
mFadeStep = (float)Math.min(1.0, 1.0 / (sampleRate * FADE_SECONDS));
setAudioDelay(sampleRate);
}
/**
* Sizes the audio delay buffers.
* @param sampleRate in hertz
*/
private void setAudioDelay(double sampleRate)
{
int length = (int)Math.ceil(sampleRate * AUDIO_DELAY_SECONDS);
mDelayedEnvelope = new float[length];
mDelayedAudioEnabled = new boolean[length];
mDelayPointer = 0;
}
/**
@@ -72,7 +102,7 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
* Demodulates the complex sample arrays.
* @param i inphase sample array
* @param q quadrature sample array
* @return demodulated AM samples with gain applied.
* @return demodulated audio samples, normalized to the carrier level, or zero valued samples while squelched.
*/
public float[] demodulate(float[] i, float[] q)
{
@@ -86,9 +116,26 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
{
mAdaptiveSquelch.process(magnitude[x]);
if(!(mAdaptiveSquelch.isUnmuted()))
boolean audioEnabled = mAdaptiveSquelch.isAudioEnabled();
float envelope = mDelayedEnvelope[mDelayPointer];
boolean delayedAudioEnabled = mDelayedAudioEnabled[mDelayPointer];
mDelayedEnvelope[mDelayPointer] = demodulated[x];
mDelayedAudioEnabled[mDelayPointer] = audioEnabled;
mDelayPointer = (mDelayPointer + 1) % mDelayedEnvelope.length;
//Fade in once the delayed audio reaches the squelch open point and fade out as soon as the squelch detects
//loss of signal, ahead of the delayed audio.
mFadeGain = (audioEnabled && delayedAudioEnabled) ? Math.min(mFadeGain + mFadeStep, 1.0f) :
Math.max(mFadeGain - mFadeStep, 0.0f);
if(mFadeGain > ZERO)
{
demodulated[x] = mAudioNormalizer.process(envelope) * mFadeGain;
}
else
{
demodulated[x] = ZERO;
mAudioNormalizer.reset();
}
if(mAdaptiveSquelch.isSquelchChanged())

View File

@@ -18,8 +18,6 @@
*/
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
@@ -32,7 +30,6 @@ public class VectorAMDemodulator128 implements IAmDemodulator
{
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_128;
private float mGain;
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
/**
* Constructs this demodulator where the specified gain is applied to demodulated output samples.

View File

@@ -18,8 +18,6 @@
*/
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
@@ -32,7 +30,6 @@ public class VectorAMDemodulator256 implements IAmDemodulator
{
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_256;
private float mGain;
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
/**
* Constructs this demodulator where the specified gain is applied to demodulated output samples.

View File

@@ -18,8 +18,6 @@
*/
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
@@ -32,7 +30,6 @@ public class VectorAMDemodulator512 implements IAmDemodulator
{
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_512;
private float mGain;
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
/**
* Constructs this demodulator where the specified gain is applied to demodulated output samples.

View File

@@ -18,8 +18,6 @@
*/
package io.github.dsheirer.dsp.am;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.vector.VectorUtilities;
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
@@ -32,7 +30,6 @@ public class VectorAMDemodulator64 implements IAmDemodulator
{
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_64;
private float mGain;
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
/**
* Constructs this demodulator where the specified gain is applied to demodulated output samples.

View File

@@ -0,0 +1,148 @@
/*
* *****************************************************************************
* 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.dsp.filter;
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.window.WindowType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Designs low-pass filters for complex sample channels, with a check of the designed frequency response.
*/
public class ChannelFilterDesigner
{
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelFilterDesigner.class);
private static final double MAXIMUM_STOP_BAND_DB = -40.0;
private static final double MAXIMUM_PASS_BAND_DB = 1.0;
private static final int FILTER_RESPONSE_CHECK_POINTS = 1024;
private static final int FALLBACK_FILTER_SIZING_ATTENUATION_DB = 75;
private static final int FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB = 150;
private static final int FALLBACK_FILTER_ATTENUATION_STEP_DB = 15;
/**
* Designs the low-pass channel filter for complex samples, where the channel bandwidth spans the two-sided
* (positive and negative frequency) signal. The stop band ripple provides approximately 46 dB of attenuation.
*
* The remez designer can converge on a filter that meets the specification at the design grid points but has a large
* response spike outside of them, so the response of the designed filter is checked before it's used.
*
* @param sampleRate of the samples to filter in hertz
* @param bandwidth of the channel in hertz
* @return filter coefficients
*/
public static float[] design(double sampleRate, double bandwidth)
{
int passBandStop = (int)(bandwidth * .4);
int stopBandStart = (int)(bandwidth * .5);
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
.sampleRate(sampleRate)
.gridDensity(16)
.oddLength(true)
.passBandCutoff(passBandStop)
.passBandAmplitude(1.0)
.passBandRipple(0.01)
.stopBandStart(stopBandStart)
.stopBandAmplitude(0.0)
.stopBandRipple(0.005)
.build();
float[] coefficients = null;
try
{
coefficients = FilterFactory.getTaps(specification);
}
catch(FilterDesignException fde)
{
LOGGER.warn("Couldn't design remez channel filter for sample rate [" + sampleRate +
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart + "]");
}
if(coefficients == null || !isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
{
LOGGER.info("Unable to use remez filter designer for sample rate [" + sampleRate + "] pass band stop [" +
passBandStop + "] and stop band start [" + stopBandStart +
"] - will proceed using simple low pass filter design");
//Center the cutoff so the bandwidth matches the remez design and size the filter to meet the response limits.
for(int attenuation = FALLBACK_FILTER_SIZING_ATTENUATION_DB; attenuation <= FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB;
attenuation += FALLBACK_FILTER_ATTENUATION_STEP_DB)
{
int length = FilterFactory.getLowPass(sampleRate, passBandStop, stopBandStart, attenuation,
WindowType.HAMMING, true).length;
coefficients = FilterFactory.getLowPass(sampleRate, (passBandStop + stopBandStart) / 2, length, WindowType.HAMMING);
if(isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
{
break;
}
}
}
return coefficients;
}
/**
* Checks the frequency response of the low-pass filter, relative to the response at DC.
* @param coefficients of the filter
* @param sampleRate of the filter in hertz
* @param passBandStop frequency in hertz
* @param stopBandStart frequency in hertz
* @return true if the pass band and stop band responses are within limits, through to half the sample rate.
*/
public static boolean isValidFilterResponse(float[] coefficients, double sampleRate, int passBandStop, int stopBandStart)
{
double dc = getResponseDb(coefficients, 0.0, sampleRate);
double nyquist = sampleRate / 2.0;
for(int x = 0; x <= FILTER_RESPONSE_CHECK_POINTS; x++)
{
double frequency = nyquist * x / FILTER_RESPONSE_CHECK_POINTS;
double response = getResponseDb(coefficients, frequency, sampleRate) - dc;
if((frequency <= passBandStop && response > MAXIMUM_PASS_BAND_DB) ||
(frequency >= stopBandStart && response > MAXIMUM_STOP_BAND_DB))
{
return false;
}
}
return true;
}
/**
* Calculates the magnitude response of the filter at the specified frequency.
*/
public static double getResponseDb(float[] coefficients, double frequency, double sampleRate)
{
double radians = 2.0 * Math.PI * frequency / sampleRate;
double real = 0.0;
double imaginary = 0.0;
for(int x = 0; x < coefficients.length; x++)
{
real += coefficients[x] * Math.cos(radians * x);
imaginary += coefficients[x] * Math.sin(radians * x);
}
return 20.0 * Math.log10(Math.hypot(real, imaginary));
}
}

View File

@@ -100,7 +100,7 @@ public class RemezFIRFilterDesigner
}
catch( FilterDesignException fde )
{
mLog.error( "Filter design error - couldn't find extremal indices at count [" +
mLog.warn( "Filter design error - couldn't find extremal indices at count [" +
iterationCount + "] try changing filter order up/down from [" +
mSpecification.getOrder() + "]" );

View File

@@ -1,161 +0,0 @@
/*
* *****************************************************************************
* 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.fm;
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
import io.github.dsheirer.sample.Listener;
import io.github.dsheirer.source.SourceEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* FM Demodulator for demodulating complex samples and producing demodulated floating point samples.
*
* Implements listener of source events to process runtime squelch threshold change request events
* which are forwarded to the power squelch control.
*/
public class SquelchingFMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
{
private static final Logger mLog = LoggerFactory.getLogger(SquelchingFMDemodulator.class);
private static final float ZERO = 0.0f;
private final AdaptiveSquelch mAdaptiveSquelch;
private final IMagnitudeCalculator mMagnitude = MagnitudeFactory.getMagnitudeCalculator();
private final IDemodulator mFmDemodulator = FmDemodulatorFactory.getFmDemodulator();
private boolean mSquelchChanged = false;
/**
* Creates an FM demodulator instance with a default gain of 1.0.
*
* @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.
* causes immediate mute and unmute. Set to higher count to prevent mute/unmute flapping.
* @param squelchAutoTrack to enable the squelch noise floor auto-tracking feature.
*/
public SquelchingFMDemodulator(float alpha, float squelchThreshold, boolean squelchAutoTrack)
{
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
}
/**
* Set or update the sample rate for the squelch to adjust the power level notification rate.
* @param sampleRate in hertz
*/
public void setSampleRate(int sampleRate)
{
mAdaptiveSquelch.setSampleRate(sampleRate);
}
/**
* Registers the listener to receive notifications of squelch change events from the power squelch.
*/
public void setSourceEventListener(Listener<SourceEvent> listener)
{
mAdaptiveSquelch.setSourceEventListener(listener);
}
/**
* Demodulates the complex (I/Q) sample arrays
* @param i inphase samples
* @param q quadrature samples
* @return demodulated real samples
*/
@Override
public float[] demodulate(float[] i, float[] q)
{
setSquelchChanged(false);
float[] demodulated = mFmDemodulator.demodulate(i, q);
float[] magnitude = mMagnitude.calculate(i, q);
for(int x = 0; x < magnitude.length; x++)
{
mAdaptiveSquelch.process(magnitude[x]);
if(!(mAdaptiveSquelch.isUnmuted()))
{
demodulated[x] = ZERO;
}
if(mAdaptiveSquelch.isSquelchChanged())
{
setSquelchChanged(true);
}
}
return demodulated;
}
/**
* Sets the threshold for squelch control
* @param threshold (dB)
*/
public void setSquelchThreshold(float threshold)
{
mAdaptiveSquelch.setSquelchThreshold(threshold);
}
@Override
public void setSquelchAutoTrack(boolean autoTrack)
{
mAdaptiveSquelch.setSquelchAutoTrack(autoTrack);
}
/**
* Indicates if the squelch state has changed during the processing of buffer(s)
*/
public boolean isSquelchChanged()
{
return mSquelchChanged;
}
/**
* Sets or resets the squelch changed flag.
*/
private void setSquelchChanged(boolean changed)
{
mSquelchChanged = changed;
}
/**
* Indicates if the squelch state is currently muted
*/
public boolean isMuted()
{
return mAdaptiveSquelch.isMuted();
}
@Override
public void receive(SourceEvent sourceEvent)
{
switch(sourceEvent.getEvent())
{
//Only forward squelch threshold and auto-track current & change requests
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
case REQUEST_CURRENT_SQUELCH_AUTO_TRACK:
case REQUEST_CHANGE_SQUELCH_AUTO_TRACK:
mAdaptiveSquelch.receive(sourceEvent);
break;
}
}
}

View File

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

View File

@@ -0,0 +1,158 @@
/*
* *****************************************************************************
* 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.dsp.squelch;
import org.jtransforms.fft.FloatFFT_1D;
/**
* Detects voice modulation on the envelope of a steady AM carrier. The noise on a carrier's envelope is spectrally flat
* across the channel, while voice energy is concentrated in the lower part of the voice band. The detector compares
* the average spectral density of a low and a high band, with band edges limited to the channel pass band.
*
* A single detection isn't proof of a voice transmission and a pause in speech isn't proof of a dead carrier, so the
* results should be evaluated across a longer period.
*/
public class VoiceModulationDetector
{
private static final float MAXIMUM_FREQUENCY = 2300.0f;
private static final float MAXIMUM_FREQUENCY_BANDWIDTH_RATIO = 0.38f;
private static final float LOW_BAND_START = 300.0f;
private static final float LOW_BAND_STOP_RATIO = 0.52f;
private static final float HIGH_BAND_START_RATIO = 0.65f;
private static final double DETECTION_THRESHOLD_DB = 5.0;
private final double mBandwidth;
private FloatFFT_1D mFFT;
private float[] mEnvelope;
private float[] mWindow;
private int mLowStartBin;
private int mLowStopBin;
private int mHighStartBin;
private int mHighStopBin;
private int mPointer = 0;
/**
* Constructs an instance
* @param sampleRate of the envelope samples in hertz
* @param bandwidth of the channel in hertz, spanning positive and negative frequencies
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
*/
public VoiceModulationDetector(double sampleRate, double bandwidth, int analysisSamples)
{
mBandwidth = bandwidth;
configure(sampleRate, analysisSamples);
}
/**
* Updates the sample rate and resets this detector.
* @param sampleRate of the envelope samples in hertz
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
*/
public final void configure(double sampleRate, int analysisSamples)
{
int length = Integer.highestOneBit(analysisSamples);
mFFT = new FloatFFT_1D(length);
mEnvelope = new float[length];
mWindow = new float[length];
for(int x = 0; x < length; x++)
{
mWindow[x] = (float)(0.5 - 0.5 * Math.cos(2.0 * Math.PI * x / length));
}
float stop = Math.min(MAXIMUM_FREQUENCY, (float)mBandwidth * MAXIMUM_FREQUENCY_BANDWIDTH_RATIO);
double hertzPerBin = sampleRate / length;
mLowStartBin = (int)Math.ceil(LOW_BAND_START / hertzPerBin);
mLowStopBin = (int)(stop * LOW_BAND_STOP_RATIO / hertzPerBin);
mHighStartBin = (int)Math.ceil(stop * HIGH_BAND_START_RATIO / hertzPerBin);
mHighStopBin = (int)(stop / hertzPerBin);
reset();
}
/**
* Discards any partial analysis buffer.
*/
public void reset()
{
mPointer = 0;
}
/**
* Adds an envelope sample to the analysis buffer. Samples are ignored once the buffer is full.
* @param envelope sample
*/
public void receive(float envelope)
{
if(mPointer < mEnvelope.length)
{
mEnvelope[mPointer++] = envelope;
}
}
/**
* Analyzes the buffered samples and resets this detector.
* @return true if the analysis buffer is full and the samples contain voice modulation.
*/
public boolean analyze()
{
boolean full = mPointer == mEnvelope.length;
mPointer = 0;
if(!full)
{
return false;
}
float mean = 0.0f;
for(float sample: mEnvelope)
{
mean += sample;
}
mean /= mEnvelope.length;
for(int x = 0; x < mEnvelope.length; x++)
{
mEnvelope[x] = (mEnvelope[x] - mean) * mWindow[x];
}
mFFT.realForward(mEnvelope);
double low = getBandPower(mLowStartBin, mLowStopBin);
double high = getBandPower(mHighStartBin, mHighStopBin);
return high > 0.0 && 10.0 * Math.log10(low / high) >= DETECTION_THRESHOLD_DB;
}
/**
* Calculates the mean power of the bins from the real transform output of interleaved real and imaginary values.
*/
private double getBandPower(int startBin, int stopBin)
{
double power = 0.0;
for(int bin = startBin; bin < stopBin; bin++)
{
double real = mEnvelope[2 * bin];
double imaginary = mEnvelope[2 * bin + 1];
power += real * real + imaginary * imaginary;
}
return power / Math.max(1, stopBin - startBin);
}
}

View File

@@ -301,7 +301,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
DecodeConfigAM.Bandwidth bandwidth = config.getBandwidth();
if(bandwidth == null)
{
bandwidth = DecodeConfigNBFM.Bandwidth.BW_15_0;
bandwidth = DecodeConfigAM.DEFAULT_BANDWIDTH;
}
for(Toggle toggle: getBandwidthButton().getToggleGroup().getToggles())
@@ -409,7 +409,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
{
getBandwidthButton().setDisable(false);
final DecodeConfigAM.Bandwidth bandwidth = (decodeConfigAM.getBandwidth() != null ?
decodeConfigAM.getBandwidth() : DecodeConfigNBFM.Bandwidth.BW_15_0);
decodeConfigAM.getBandwidth() : DecodeConfigAM.DEFAULT_BANDWIDTH);
for(Toggle toggle: getBandwidthButton().getToggleGroup().getToggles())
{
@@ -454,7 +454,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
config = new DecodeConfigAM();
}
DecodeConfigAM.Bandwidth bandwidth = DecodeConfigAM.Bandwidth.BW_15_0;
DecodeConfigAM.Bandwidth bandwidth = DecodeConfigAM.DEFAULT_BANDWIDTH;
if(getBandwidthButton().getToggleGroup().getSelectedToggle() != null)
{

View File

@@ -109,6 +109,7 @@ public class SignalPowerView extends JPanel
mSquelchAutoTrackCheckBox.addActionListener(e ->
{
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
setConfigSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected());
});
valuePanel.add(mSquelchAutoTrackCheckBox, "span,left");
@@ -116,7 +117,11 @@ public class SignalPowerView extends JPanel
}
/**
* Updates the channel's decode configuration with a new squelch threshold value
* Updates the channel's decode configuration with a new squelch threshold value.
*
* Note: this is only used for the user threshold notification that follows a user change. The threshold
* notifications from the decoder can be values that are automatically tracked from current channel conditions, and
* saving those would replace the user's setting.
*/
private void setConfigSquelchThreshold(int threshold)
{
@@ -195,7 +200,6 @@ public class SignalPowerView extends JPanel
{
final double threshold = sourceEvent.getValue().doubleValue();
mSquelchThreshold = threshold;
setConfigSquelchThreshold((int)threshold);
EventQueue.invokeLater(() -> {
mPowerMeter.setSquelchThreshold(threshold);
@@ -206,10 +210,11 @@ public class SignalPowerView extends JPanel
mSquelchUpButton.setEnabled(true);
});
}
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
setConfigSquelchThreshold((int)Math.round(sourceEvent.getValue().doubleValue()));
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
{
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
setConfigSquelchAutoTrack(autoTrack);
EventQueue.invokeLater(() -> {
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
mSquelchAutoTrackCheckBox.setEnabled(true);
@@ -225,5 +230,9 @@ public class SignalPowerView extends JPanel
{
mProcessingChain = processingChain;
reset();
//Request the current squelch settings, which enables the squelch controls when the decoder responds.
broadcast(SourceEvent.requestCurrentSquelchThreshold());
broadcast(SourceEvent.requestCurrentSquelchAutoTrack());
}
}

View File

@@ -21,20 +21,14 @@ package io.github.dsheirer.module.decode.am;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.IDecoderStateEventProvider;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.dsp.am.ISquelchingDemodulator;
import io.github.dsheirer.dsp.am.SquelchingAMDemodulator;
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.resample.RealResampler;
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
import io.github.dsheirer.dsp.gain.AudioGainAndDcFilter;
import io.github.dsheirer.dsp.squelch.INoiseSquelchController;
import io.github.dsheirer.dsp.squelch.NoiseSquelch;
import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.SquelchControlDecoder;
import io.github.dsheirer.sample.Listener;
@@ -52,15 +46,9 @@ import org.slf4j.LoggerFactory;
*/
public class AMDecoder extends SquelchControlDecoder implements ISourceEventListener, ISourceEventProvider,
IComplexSamplesListener, Listener<ComplexSamples>, IRealBufferProvider,
IDecoderStateEventProvider, INoiseSquelchController
IDecoderStateEventProvider
{
private final static Logger mLog = LoggerFactory.getLogger(AMDecoder.class);
private static final float DEMODULATOR_GAIN = 150.0f;
private static final float SQUELCH_ALPHA_DECAY = 0.0004f;
private static final float MINIMUM_GAIN = 0.5f;
private static final float MAXIMUM_GAIN = 16.0f;
private static final float OBJECTIVE_AUDIO_AMPLITUDE = 0.75f;
private AudioGainAndDcFilter mAGC = new AudioGainAndDcFilter(MINIMUM_GAIN, MAXIMUM_GAIN, OBJECTIVE_AUDIO_AMPLITUDE);
private static final double DEMODULATED_AUDIO_SAMPLE_RATE = 8000.0;
private IRealFilter mIBasebandFilter;
private IRealFilter mQBasebandFilter;
@@ -73,7 +61,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
private Listener<DecoderStateEvent> mDecoderStateEventListener;
private final double mChannelBandwidth;
protected boolean mSquelch = true;
private final NoiseSquelch mNoiseSquelch = new NoiseSquelch(0.1f, 0.2f, 4, 6);
/**
* Constructs an instance
@@ -83,8 +70,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
public AMDecoder(DecodeConfigAM config)
{
super(config);
mDemodulator = new SquelchingAMDemodulator(DEMODULATOR_GAIN, SQUELCH_ALPHA_DECAY, config.getSquelchThreshold(),
config.isSquelchAutoTrack());
mDemodulator = new SquelchingAMDemodulator(config.getSquelchThreshold(), config.isSquelchAutoTrack(),
config.getBandwidth().getValue());
mChannelBandwidth = config.getBandwidth().getValue();
}
@@ -94,30 +81,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
return DecoderType.AM;
}
@Override
public void setSquelchOverride(boolean override)
{
mNoiseSquelch.setSquelchOverride(override);
}
@Override
public void setHysteresisThreshold(int open, int close)
{
mNoiseSquelch.setHysteresisThreshold(open, close);
}
@Override
public void setNoiseThreshold(float open, float close)
{
mNoiseSquelch.setNoiseThreshold(open, close);
}
@Override
public void setNoiseSquelchStateListener(Listener<NoiseSquelchState> listener)
{
mNoiseSquelch.setNoiseSquelchStateListener(listener);
}
/**
* Implements the ISourceEventListener interface
*/
@@ -154,8 +117,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
if(mResampledBufferListener != null)
{
mResampledBufferListener.receive(demodulatedSamples);
// //Apply audio gain and rebroadcast
// super.broadcast(mAGC.process(demodulatedSamples));
}
}
@@ -196,7 +157,7 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
{
if(mIDecimationFilter == null || mQDecimationFilter == null)
{
throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source " +
throw new IllegalStateException("AM demodulator module must receive a sample rate change source " +
"event before it can process complex sample buffers");
}
@@ -208,8 +169,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ);
mNoiseSquelch.process(demodulated);
if(mResampler != null)
{
//Squelch changed while processing this audio buffer
@@ -225,6 +184,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
//Demodulator says to mute and we are unmuted = Squelch and End Call
else
{
//Process the final audio and flush the resampler so that audio doesn't carry into the next call
mResampler.resample(demodulated, true);
mSquelch = true;
notifyCallEnd();
}
@@ -275,7 +236,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
*/
protected void notifyCallStart()
{
mAGC.reset();
broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0));
}
@@ -398,53 +358,17 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
if((decimatedSampleRate < (2.0 * mChannelBandwidth)))
//The complex sample rate must span the two-sided channel bandwidth.
if(decimatedSampleRate < mChannelBandwidth)
{
throw new IllegalStateException(getDecoderType().name() + " demodulator with channel bandwidth [" +
mChannelBandwidth + "] requires a channel sample rate of [" + (2.0 * mChannelBandwidth +
"] - sample rate of [" + decimatedSampleRate + "] is not supported"));
mChannelBandwidth + "] requires a channel sample rate of at least [" + mChannelBandwidth +
"] - sample rate of [" + decimatedSampleRate + "] is not supported");
}
mDemodulator.setSampleRate((int) decimatedSampleRate);
mNoiseSquelch.setSampleRate(decimatedSampleRate);
int passBandStop = (int) (mChannelBandwidth * .8);
int stopBandStart = (int) mChannelBandwidth;
float[] coefficients = null;
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
.sampleRate(decimatedSampleRate * 2)
.gridDensity(16)
.oddLength(true)
.passBandCutoff(passBandStop)
.passBandAmplitude(1.0)
.passBandRipple(0.01)
.stopBandStart(stopBandStart)
.stopBandAmplitude(0.0)
.stopBandRipple(0.005) //Approximately 90 dB attenuation
.build();
try
{
coefficients = FilterFactory.getTaps(specification);
}
catch(FilterDesignException fde)
{
mLog.error("Couldn't design demodulator remez filter for sample rate [" + sampleRate +
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart +
"] - will proceed using sinc (low-pass) filter");
}
if(coefficients == null)
{
mLog.info("Unable to use remez filter designer for sample rate [" + decimatedSampleRate +
"] pass band stop [" + passBandStop +
"] and stop band start [" + stopBandStart + "] - will proceed using simple low pass filter design");
coefficients = FilterFactory.getLowPass(decimatedSampleRate, passBandStop, stopBandStart, 60,
WindowType.HAMMING, true);
}
float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth);
mIBasebandFilter = FilterFactory.getRealFilter(coefficients);
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);

View File

@@ -30,6 +30,7 @@ import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
*/
public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfiguration
{
public static final Bandwidth DEFAULT_BANDWIDTH = Bandwidth.BW_6_0;
private int mSquelchThreshold = -78;
private boolean mSquelchAutoTrack = true;
@@ -46,7 +47,7 @@ public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfig
@Override
protected Bandwidth getDefaultBandwidth()
{
return Bandwidth.BW_15_0;
return DEFAULT_BANDWIDTH;
}
/**
@@ -62,12 +63,14 @@ public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfig
return new ChannelSpecification(25000.0, 3000, 1500.0, 1700.0);
case BW_5_0:
return new ChannelSpecification(25000.0, 5000, 2500.0, 2700.0);
case BW_6_0:
return new ChannelSpecification(25000.0, 6000, 3000.0, 3200.0);
case BW_8_33:
return new ChannelSpecification(25000.0, 10000, 5000.0, 7000.0);
case BW_15_0:
return new ChannelSpecification(25000.0, 15000, 7500.0, 9500.0);
return new ChannelSpecification(30000.0, 15000, 7500.0, 9500.0);
case BW_25_0:
return new ChannelSpecification(25000.0, 25000, 12500.0, 14500.0);
return new ChannelSpecification(50000.0, 25000, 12500.0, 14500.0);
default:
throw new IllegalArgumentException("Unrecognized AM bandwidth value: " + getBandwidth());
}

View File

@@ -100,6 +100,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
{
BW_3_0("3.0 kHz", 3000.0),
BW_5_0("5.0 kHz", 5000.0),
BW_6_0("6.0 kHz", 6000.0),
BW_7_5("7.5 kHz", 7500.0),
BW_8_33("8.33 kHz", 8333.0),
BW_12_5("12.5 kHz", 12500.0),
@@ -121,7 +122,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
}
//AM demodulator channel bandwidth options
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_8_33, BW_15_0, BW_25_0);
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_6_0, BW_8_33, BW_15_0, BW_25_0);
//FM demodulator channel bandwidth options
public static EnumSet<Bandwidth> FM_BANDWIDTHS = EnumSet.of(BW_7_5, BW_12_5, BW_25_0);

View File

@@ -22,11 +22,10 @@ import io.github.dsheirer.audio.squelch.SquelchState;
import io.github.dsheirer.channel.state.DecoderStateEvent;
import io.github.dsheirer.channel.state.IDecoderStateEventProvider;
import io.github.dsheirer.channel.state.State;
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.resample.RealResampler;
import io.github.dsheirer.dsp.fm.FmDemodulatorFactory;
@@ -34,7 +33,6 @@ import io.github.dsheirer.dsp.fm.IDemodulator;
import io.github.dsheirer.dsp.squelch.INoiseSquelchController;
import io.github.dsheirer.dsp.squelch.NoiseSquelch;
import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.module.decode.DecoderType;
import io.github.dsheirer.module.decode.SquelchControlDecoder;
import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
@@ -456,37 +454,7 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
mNoiseSquelch.setSampleRate(decimatedSampleRate);
int passBandStop = (int) (mChannelBandwidth * .8);
int stopBandStart = (int) mChannelBandwidth;
float[] coefficients = null;
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
.sampleRate(decimatedSampleRate * 2)
.gridDensity(16)
.oddLength(true)
.passBandCutoff(passBandStop)
.passBandAmplitude(1.0)
.passBandRipple(0.01)
.stopBandStart(stopBandStart)
.stopBandAmplitude(0.0)
.stopBandRipple(0.005)
.build();
try
{
coefficients = FilterFactory.getTaps(specification);
}
catch(FilterDesignException fde)
{
mLog.error("Couldn't design demodulator remez filter for sample rate [" + sampleRate + "] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart + "] - will proceed using sinc (low-pass) filter");
}
if(coefficients == null)
{
mLog.info("Unable to use remez filter designer for sample rate [" + decimatedSampleRate + "] pass band stop [" + passBandStop + "] and stop band start [" + stopBandStart + "] - will proceed using simple low pass filter design");
coefficients = FilterFactory.getLowPass(decimatedSampleRate, passBandStop, stopBandStart, 60, WindowType.HAMMING, true);
}
float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth);
mIBasebandFilter = FilterFactory.getRealFilter(coefficients);
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);

View File

@@ -45,6 +45,7 @@ public class SourceEvent
NOTIFICATION_RECORDING_FILE_LOADED,
NOTIFICATION_SAMPLE_RATE_CHANGE,
NOTIFICATION_SQUELCH_THRESHOLD,
NOTIFICATION_SQUELCH_USER_THRESHOLD,
NOTIFICATION_SQUELCH_AUTO_TRACK,
NOTIFICATION_STOP_SAMPLE_STREAM,
NOTIFICATION_TUNER_SHUTDOWN,
@@ -428,6 +429,17 @@ public class SourceEvent
return new SourceEvent(Event.NOTIFICATION_SQUELCH_THRESHOLD, source, thresholdDb);
}
/**
* Creates a new squelch user threshold notification, which is the setting to save after a user change request. This
* can differ from the current squelch threshold when auto-tracking the noise floor.
* @param thresholdDb setting
* @return new source event
*/
public static SourceEvent squelchUserThreshold(double thresholdDb)
{
return new SourceEvent(Event.NOTIFICATION_SQUELCH_USER_THRESHOLD, null, thresholdDb);
}
/**
* Requests the current squelch threshold value
* @return source event

View File

@@ -204,6 +204,7 @@ public abstract class TunerChannelSource extends ComplexSource implements ISourc
case NOTIFICATION_FREQUENCY_ROTATION_SUCCESS:
case NOTIFICATION_CHANNEL_POWER:
case NOTIFICATION_SQUELCH_THRESHOLD:
case NOTIFICATION_SQUELCH_USER_THRESHOLD:
case NOTIFICATION_SQUELCH_AUTO_TRACK:
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
case REQUEST_CURRENT_SQUELCH_THRESHOLD:

View File

@@ -0,0 +1,44 @@
/*
* *****************************************************************************
* 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.audio.playback;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
/**
* Unit tests for audio provider sample conversion
*/
class AudioProviderPcmTest
{
@Test
void outOfRangeSamplesAreLimitedInsteadOfWrapping()
{
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.35f));
assertEquals(-Short.MAX_VALUE, AudioProvider.toPcm(-26.0f));
}
@Test
void inRangeSamplesAreScaled()
{
assertEquals(0, AudioProvider.toPcm(0.0f));
assertEquals(16383, AudioProvider.toPcm(0.5f));
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.0f));
}
}

View File

@@ -0,0 +1,121 @@
/*
* *****************************************************************************
* 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.dsp.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the AM audio normalizer
*/
class AmAudioNormalizerTest
{
private static final double SAMPLE_RATE = 25000.0;
private static final float MODULATION = 0.8f;
private static float envelope(float carrier, int sample)
{
return carrier * (1.0f + MODULATION * (float)Math.cos(2.0 * Math.PI * 1000.0 / SAMPLE_RATE * sample));
}
@Test
void outputFollowsModulationDepthAtAnyCarrierLevel()
{
for(float carrier: new float[]{0.0005f, 0.002f, 0.01f, 0.03f, 0.1f})
{
checkOutput(carrier);
}
}
private static void checkOutput(float carrier)
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
double sum = 0.0;
float peak = 0.0f;
int settle = (int)SAMPLE_RATE;
int count = (int)SAMPLE_RATE;
for(int x = 0; x < settle + count; x++)
{
float audio = normalizer.process(envelope(carrier, x));
if(x >= settle)
{
sum += audio;
peak = Math.max(peak, Math.abs(audio));
}
}
assertEquals(0.6f, peak, 0.02f, "Peak");
assertEquals(0.0, sum / count, 0.002, "DC");
}
@Test
void transmissionStartHasNoTransient()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < (int)SAMPLE_RATE; x++)
{
assertTrue(Math.abs(normalizer.process(envelope(0.01f, x))) < 0.8f, "Transient at sample " + x);
}
}
@Test
void noiseAfterCarrierDropIsNotAmplifiedToCarrierLevel()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < (int)SAMPLE_RATE; x++)
{
normalizer.process(envelope(0.01f, x));
}
java.util.Random random = new java.util.Random(1);
float peak = 0.0f;
//Noise is 40 dB below the carrier. Skip the carrier estimate decay.
for(int x = 0; x < (int)(SAMPLE_RATE * 0.5); x++)
{
float audio = normalizer.process(0.0001f * (1.0f + random.nextFloat()));
if(x > (int)(SAMPLE_RATE * 0.2))
{
peak = Math.max(peak, Math.abs(audio));
}
}
assertTrue(peak < 0.05f, "Noise peak " + peak);
}
@Test
void outputIsLimited()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < 1000; x++)
{
normalizer.process(0.001f);
}
assertTrue(normalizer.process(1.0f) <= 0.95f);
}
}

View File

@@ -0,0 +1,105 @@
/*
* *****************************************************************************
* 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.dsp.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the AM demodulator squelch transitions
*/
class SquelchingAMDemodulatorTest
{
private static final int SAMPLE_RATE = 25000;
private static final int BUFFER = 1024;
private static final double TONE = 1000.0;
/**
* Demodulates silence, then a transmission, then silence.
*/
private static float[] transmission(float carrier)
{
SquelchingAMDemodulator demodulator = new SquelchingAMDemodulator(-78.0f, false, 6000.0);
demodulator.setSampleRate(SAMPLE_RATE);
int buffers = 150;
float[] audio = new float[BUFFER * buffers];
for(int b = 0; b < buffers; b++)
{
float[] i = new float[BUFFER];
float[] q = new float[BUFFER];
if(b >= 30 && b < 100)
{
for(int x = 0; x < BUFFER; x++)
{
int sample = b * BUFFER + x;
i[x] = carrier * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * TONE * sample / SAMPLE_RATE));
}
}
System.arraycopy(demodulator.demodulate(i, q), 0, audio, b * BUFFER, BUFFER);
}
return audio;
}
@Test
void transmissionHasNoClicks()
{
for(float carrier: new float[]{0.001f, 0.01f, 0.1f})
{
float[] audio = transmission(carrier);
float peak = 0.0f;
float step = 0.0f;
for(int x = 1; x < audio.length; x++)
{
peak = Math.max(peak, Math.abs(audio[x]));
step = Math.max(step, Math.abs(audio[x] - audio[x - 1]));
}
//Expected 80% modulated peak is 0.6 and the largest step of the 1 kHz tone is 0.15
assertTrue(peak < 0.7f, "Peak " + peak + " for carrier " + carrier);
assertTrue(step < 0.2f, "Step " + step + " for carrier " + carrier);
}
}
@Test
void audioEndsWithTheTransmission()
{
float[] audio = transmission(0.01f);
int last = 0;
for(int x = 0; x < audio.length; x++)
{
if(audio[x] != 0.0f)
{
last = x;
}
}
//Audio is delayed by 32 ms, so it can't run past the end of the transmission plus the delay.
int end = 100 * BUFFER;
assertTrue(last <= end + SAMPLE_RATE * 0.032, "Last audio sample " + last + " for end at " + end);
assertEquals(0.0f, audio[audio.length - 1]);
}
}

View File

@@ -0,0 +1,98 @@
/*
* *****************************************************************************
* 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.dsp.filter;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the channel filter design
*/
class ChannelFilterDesignerTest
{
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 7500.0, 8333.0, 12500.0, 15000.0, 25000.0};
/**
* Each channel filter must be valid across decimated sample rates, which includes rates where the remez designer
* previously produced a filter with a large response spike at half of the sample rate.
*/
@Test
void channelFilterResponseIsValidForDecimatedSampleRates()
{
for(double bandwidth: BANDWIDTHS)
{
for(double sampleRate = bandwidth; sampleRate < 4.0 * bandwidth; sampleRate += bandwidth / 32.0)
{
check(sampleRate, bandwidth);
}
}
}
@Test
void tunerDerivedSampleRates()
{
check(11718.75, 3000.0);
check(19531.25, 5000.0);
check(50000.0, 15000.0);
check(29296.875, 7500.0);
check(48828.125, 12500.0);
check(25000.0, 7500.0);
}
/**
* These sample rates use the fallback filter design, which must keep the same bandwidth as the remez design.
*/
@Test
void fallbackFilterKeepsChannelBandwidth()
{
checkPassBand(11718.75, 3000.0);
checkPassBand(19531.25, 5000.0);
checkPassBand(50000.0, 15000.0);
}
private static void checkPassBand(double sampleRate, double bandwidth)
{
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
double response = ChannelFilterDesigner.getResponseDb(coefficients, bandwidth * .4, sampleRate) -
ChannelFilterDesigner.getResponseDb(coefficients, 0.0, sampleRate);
assertTrue(response > -1.0, "Pass band edge response [" + response + " dB] at sample rate [" + sampleRate + "]");
}
private static void check(double sampleRate, double bandwidth)
{
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
assertTrue(ChannelFilterDesigner.isValidFilterResponse(coefficients, sampleRate, (int)(bandwidth * .4), (int)(bandwidth * .5)),
"Filter response for sample rate [" + sampleRate + "] bandwidth [" + bandwidth + "]");
}
@Test
void responseCheckRejectsSpikeAtNyquist()
{
//Low-pass moving average with an added alternating sequence that peaks at half of the sample rate.
float[] coefficients = new float[31];
for(int x = 0; x < coefficients.length; x++)
{
coefficients[x] = 1.0f / coefficients.length + (x % 2 == 0 ? 0.1f : -0.1f);
}
assertTrue(!ChannelFilterDesigner.isValidFilterResponse(coefficients, 25000.0, 3000, 4000));
}
}

View File

@@ -0,0 +1,387 @@
/*
* *****************************************************************************
* 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.dsp.squelch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.source.SourceEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the adaptive squelch
*/
class AdaptiveSquelchTest
{
private static final int SAMPLE_RATE = 25000;
private static final float THRESHOLD_DB = -78.0f;
private static final float NOISE = (float)Math.pow(10.0, -9.0); //-90 dB
private static AdaptiveSquelch squelch(int sampleRate, boolean autoTrack)
{
AdaptiveSquelch squelch = new AdaptiveSquelch(THRESHOLD_DB, autoTrack, 6000.0);
squelch.setSampleRate(sampleRate);
return squelch;
}
private static void process(AdaptiveSquelch squelch, float power, double seconds, int sampleRate)
{
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
squelch.process(power);
}
}
/**
* Seconds of samples at the power level until the audio is disabled and until the squelch closes.
*/
private static double[] closeTimes(float signal, int sampleRate)
{
AdaptiveSquelch squelch = squelch(sampleRate, false);
process(squelch, signal, 0.5, sampleRate);
assertTrue(squelch.isUnmuted());
double audio = -1.0;
for(int x = 0; x < sampleRate; x++)
{
squelch.process(NOISE);
if(audio < 0.0 && !squelch.isAudioEnabled())
{
audio = (double)x / sampleRate;
}
if(squelch.isMuted())
{
return new double[]{audio, (double)x / sampleRate};
}
}
throw new AssertionError("Squelch didn't close");
}
@Test
void closeTimeIsIndependentOfSignalLevelAndSampleRate()
{
for(int sampleRate: new int[]{6250, 25000, 50000})
{
for(float signal: new float[]{1E-7f, 1E-6f, 1E-4f, 1E-2f})
{
double[] times = closeTimes(signal, sampleRate);
assertEquals(0.0175, times[0], 0.0085, "Audio disable " + signal + " at " + sampleRate);
assertEquals(0.1175, times[1], 0.0085, "Close " + signal + " at " + sampleRate);
}
}
}
@Test
void startsMuted()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
assertTrue(squelch.isMuted());
assertFalse(squelch.isAudioEnabled());
}
@Test
void shortNoiseBurstDoesNotOpen()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
process(squelch, NOISE, 0.1, SAMPLE_RATE);
process(squelch, 1E-4f, 0.005, SAMPLE_RATE);
process(squelch, NOISE, 0.1, SAMPLE_RATE);
assertTrue(squelch.isMuted());
}
@Test
void hysteresisHoldsOpenBelowOpenThreshold()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
process(squelch, threshold * 1.2f, 0.1, SAMPLE_RATE);
assertTrue(squelch.isUnmuted());
process(squelch, threshold * 0.6f, 2.0, SAMPLE_RATE); //2 dB below open, above close
assertTrue(squelch.isAudioEnabled());
}
@Test
void fadeShorterThanHangKeepsCallOpen()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
process(squelch, 1E-5f, 0.1, SAMPLE_RATE);
process(squelch, NOISE, 0.06, SAMPLE_RATE);
assertFalse(squelch.isAudioEnabled());
assertTrue(squelch.isUnmuted());
process(squelch, 1E-5f, 0.03, SAMPLE_RATE);
assertTrue(squelch.isAudioEnabled());
}
@Test
void lowFrequencyModulationDoesNotDisableAudio()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
process(squelch, threshold * 2.0f, 0.1, SAMPLE_RATE);
//Carrier 3 dB over the open threshold with 100% modulation at 100 Hz
for(int x = 0; x < SAMPLE_RATE * 2; x++)
{
double envelope = 1.0 + Math.cos(2.0 * Math.PI * 100.0 * x / SAMPLE_RATE);
squelch.process((float)(threshold * 2.0 / 1.5 * envelope * envelope));
assertTrue(squelch.isAudioEnabled(), "Audio disabled at sample " + x);
}
}
/**
* Sample powers with the exponential distribution of complex gaussian noise.
*/
private static void noise(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * power));
}
}
/**
* Sample powers for a steady carrier with noise 20 dB below the carrier
*/
private static void carrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
double sigma = Math.sqrt(power * 0.01 / 2.0);
double amplitude = Math.sqrt(power);
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
double i = amplitude + sigma * random.nextGaussian();
double q = sigma * random.nextGaussian();
squelch.process((float)(i * i + q * q));
}
}
@Test
void thresholdBelowNoiseFloorDoesNotLatchOpen()
{
Random random = new Random(1);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
float noise = (float)Math.pow(10.0, -7.4); //-74 dB is above the -78 dB threshold
for(int x = 0; x < SAMPLE_RATE * 30; x++)
{
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * noise));
assertTrue(squelch.isMuted(), "Opened at sample " + x);
}
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void transmissionOpensOverLearnedNoiseFloor()
{
Random random = new Random(2);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random); //-64 dB
assertTrue(squelch.isAudioEnabled());
noise(squelch, 4E-8f, 0.5, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void userMarginAboveMinimumIsKept()
{
Random random = new Random(3);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB, with a -78 dB threshold
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void autoTrackKeepsDecibelMarginWhenNoiseFloorRises()
{
Random random = new Random(4);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB
//Raise the noise floor 16 dB in steps that stay under the squelch threshold.
for(int step = 1; step <= 8; step++)
{
noise(squelch, 1E-9f * (float)Math.pow(10.0, step * 0.2), 11.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted(), "Opened at step " + step);
}
assertEquals(THRESHOLD_DB + 16.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void steadyCarrierPresentAtStartPassesUntilTimeoutThenMutes()
{
Random random = new Random(5);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
carrier(squelch, 2.5E-7f, 59.0, SAMPLE_RATE, random); //-66 dB
assertTrue(squelch.isAudioEnabled());
carrier(squelch, 2.5E-7f, 3.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
carrier(squelch, 2.5E-7f, 30.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
}
@Test
void steadyCarrierAfterNoisePassesUntilTimeoutThenMutes()
{
Random random = new Random(6);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 59.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
carrier(squelch, 4E-7f, 3.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
}
@Test
void transmissionsAreHeardAfterCarrierTimeoutEnds()
{
Random random = new Random(7);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
}
@Test
void autoTrackDisabledUsesUserThreshold()
{
Random random = new Random(8);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //Noise above the threshold opens the squelch
assertTrue(squelch.isUnmuted());
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.01f);
}
/**
* Requests the threshold and returns the user threshold notification value.
*/
private static double requestThreshold(AdaptiveSquelch squelch, double threshold)
{
List<SourceEvent> events = new ArrayList<>();
squelch.setSourceEventListener(events::add);
squelch.receive(SourceEvent.requestSquelchThreshold(null, threshold));
squelch.setSourceEventListener(null);
return events.stream().filter(event -> event.getEvent() == SourceEvent.Event.NOTIFICATION_SQUELCH_USER_THRESHOLD)
.findFirst().orElseThrow().getValue().doubleValue();
}
@Test
void thresholdChangeDuringTimedOutCarrierIsRelativeToNoiseFloor()
{
Random random = new Random(9);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB with a -68 dB threshold
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random); //-64 dB with a -58 dB threshold
assertEquals(-58.0f, squelch.getSquelchThreshold(), 0.5f);
//One dB up from the displayed threshold saves one dB over the -68 dB threshold, not -57 dB
assertEquals(-67.0, requestThreshold(squelch, squelch.getSquelchThreshold() + 1.0), 0.5);
assertEquals(-57.0f, squelch.getSquelchThreshold(), 0.5f);
//The setting applies when the carrier ends
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
assertEquals(-67.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void thresholdChangeBelowMinimumMarginIsLimited()
{
Random random = new Random(10);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
assertEquals(-68.0, requestThreshold(squelch, -72.0), 0.5);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void thresholdChangeWithAutoTrackDisabledIsUnchanged()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
assertEquals(-72.0, requestThreshold(squelch, -72.0), 0.01);
assertEquals(-72.0f, squelch.getSquelchThreshold(), 0.01f);
}
/**
* Sample powers for a carrier with noise 20 dB below the carrier and bursts of low frequency voice-like modulation.
*/
private static void voiceCarrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
double sigma = Math.sqrt(power * 0.01 / 2.0);
double amplitude = Math.sqrt(power);
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
double time = (double)x / sampleRate;
double modulation = 0.3 * Math.cos(2.0 * Math.PI * 500.0 * time) + 0.2 * Math.cos(2.0 * Math.PI * 800.0 * time);
double i = amplitude * (1.0 + modulation) + sigma * random.nextGaussian();
double q = sigma * random.nextGaussian();
squelch.process((float)(i * i + q * q));
}
}
@Test
void carrierWithVoiceModulationDoesNotTimeOut()
{
Random random = new Random(11);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
voiceCarrier(squelch, 4E-7f, 190.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void voiceOnTimedOutCarrierReopensSquelch()
{
Random random = new Random(12);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
assertTrue(squelch.isAudioEnabled());
}
@Test
void voiceOnCarrierPresentAtStartReopensSquelch()
{
Random random = new Random(13);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
}
}

View File

@@ -0,0 +1,86 @@
/*
* *****************************************************************************
* 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.dsp.squelch;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the voice modulation detector
*/
class VoiceModulationDetectorTest
{
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 8333.0, 15000.0, 25000.0};
/**
* Fills the detector with a carrier having white envelope noise and optional modulation.
*/
private static boolean analyze(double sampleRate, double bandwidth, double modulation, Random random)
{
int samples = (int)(sampleRate / 2.0);
VoiceModulationDetector detector = new VoiceModulationDetector(sampleRate, bandwidth, samples);
for(int x = 0; x < samples; x++)
{
double time = x / sampleRate;
detector.receive((float)(1.0 + modulation * Math.cos(2.0 * Math.PI * 450.0 * time) + 0.1 * random.nextGaussian()));
}
return detector.analyze();
}
@Test
void flatEnvelopeNoiseIsNotVoice()
{
Random random = new Random(1);
for(double bandwidth: BANDWIDTHS)
{
for(double sampleRate: new double[]{25000.0, 39062.5, 50000.0})
{
for(int x = 0; x < 50; x++)
{
assertFalse(analyze(sampleRate, bandwidth, 0.0, random), "Bandwidth " + bandwidth);
}
}
}
}
@Test
void lowFrequencyModulationIsVoice()
{
Random random = new Random(2);
for(double bandwidth: BANDWIDTHS)
{
assertTrue(analyze(39062.5, bandwidth, 0.2, random), "Bandwidth " + bandwidth);
}
}
@Test
void partialBufferIsNotAnalyzed()
{
VoiceModulationDetector detector = new VoiceModulationDetector(25000.0, 6000.0, 12500);
detector.receive(1.0f);
assertFalse(detector.analyze());
}
}

View File

@@ -0,0 +1,136 @@
/*
* *****************************************************************************
* 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.module.decode.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.module.decode.analog.DecodeConfigAnalog.Bandwidth;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the channel sample rates supported by the AM decoder.
*/
class AMDecoderSampleRateTest
{
private static final int BUFFER = 2048;
private static AMDecoder decoder(Bandwidth bandwidth, double sampleRate)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
AMDecoder decoder = new AMDecoder(config);
decoder.getSourceEventListener().receive(SourceEvent.sampleRateChange(sampleRate));
return decoder;
}
@Test
void defaultBandwidthIsSixKilohertz()
{
assertEquals(Bandwidth.BW_6_0, new DecodeConfigAM().getBandwidth());
assertTrue(Bandwidth.BW_6_0.isAM());
}
@Test
void minimumChannelSampleRateIsAtLeastTwiceTheBandwidth()
{
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
assertTrue(config.getChannelSpecification().getMinimumSampleRate() >= 2.0 * bandwidth.getValue(), bandwidth.name());
}
}
/**
* Follows the heterodyne channel source decimation selection for tuner sample rates, including 1.8 and 3.6 MHz which
* previously produced an unsupported 28,125 Hz sample rate for the default 15 kHz bandwidth.
*/
@Test
void heterodyneChannelRatesAreSupported()
{
double[] tunerRates = {1.8E6, 2.0E6, 2.4E6, 2.56E6, 3.0E6, 3.2E6, 3.6E6, 6.0E6, 7.2E6, 8.0E6, 10.0E6, 20.0E6};
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
ChannelSpecification specification = config.getChannelSpecification();
for(double tunerRate: tunerRates)
{
int decimation = DecimationFilterFactory.getDecimationRate((int)(tunerRate / specification.getMinimumSampleRate()));
decoder(bandwidth, tunerRate / decimation);
}
}
}
/**
* Polyphase channel rates don't use the channel specification minimum rate.
*/
@Test
void ratesBelowTwiceTheBandwidthDecodeAudio()
{
for(double sampleRate: new double[]{25000.0, 28125.0})
{
AMDecoder decoder = decoder(Bandwidth.BW_15_0, sampleRate);
List<float[]> audio = new ArrayList<>();
decoder.setBufferListener(audio::add);
int sample = 0;
for(int b = 0; b < (int)(sampleRate * 2.0 / BUFFER); b++)
{
float[] i = new float[BUFFER];
float[] q = new float[BUFFER];
for(int x = 0; x < BUFFER; x++)
{
i[x] = 0.01f * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * 1000.0 * sample++ / sampleRate));
}
decoder.receive(new ComplexSamples(i, q, 0L));
}
float peak = 0.0f;
for(float[] buffer: audio.subList(audio.size() / 2, audio.size()))
{
for(float value: buffer)
{
peak = Math.max(peak, Math.abs(value));
}
}
assertEquals(0.6f, peak, 0.05f, "Peak at sample rate " + sampleRate);
}
}
@Test
void rateBelowTheBandwidthIsRejected()
{
assertThrows(IllegalStateException.class, () -> decoder(Bandwidth.BW_25_0, 20000.0));
}
}

View File

@@ -0,0 +1,63 @@
/*
* *****************************************************************************
* 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.module.decode.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.github.dsheirer.source.SourceEvent;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the source event requests that the squelch controls use to get the current squelch settings.
*/
class AMDecoderSquelchRequestTest
{
private static List<SourceEvent> events(DecodeConfigAM config, SourceEvent request)
{
AMDecoder decoder = new AMDecoder(config);
List<SourceEvent> events = new ArrayList<>();
decoder.setSourceEventListener(events::add);
decoder.getSourceEventListener().receive(request);
return events;
}
@Test
void currentThresholdRequestIsAnswered()
{
DecodeConfigAM config = new DecodeConfigAM();
config.setSquelchThreshold(-70);
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchThreshold());
assertEquals(1, events.size());
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_THRESHOLD, events.getFirst().getEvent());
assertEquals(-70.0, events.getFirst().getValue().doubleValue(), 0.01);
}
@Test
void currentAutoTrackRequestIsAnswered()
{
DecodeConfigAM config = new DecodeConfigAM();
config.setSquelchAutoTrack(false);
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchAutoTrack());
assertEquals(1, events.size());
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_AUTO_TRACK, events.getFirst().getEvent());
assertEquals(0, events.getFirst().getValue().intValue());
}
}