diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java
index f524c3f2..4947eb67 100644
--- a/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java
+++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProvider.java
@@ -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
*/
diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java
index 7e3d6217..9e0026c6 100644
--- a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java
+++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMono.java
@@ -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;
diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java
index 274ff9ca..6d43007f 100644
--- a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java
+++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderMultiChannel.java
@@ -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]));
}
}
diff --git a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java
index bf3c59fb..5cca2c7a 100644
--- a/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java
+++ b/src/main/java/io/github/dsheirer/audio/playback/AudioProviderStereo.java
@@ -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;
diff --git a/src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java b/src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
new file mode 100644
index 00000000..924c5bfe
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
@@ -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
+ * ****************************************************************************
+ */
+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);
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/dsp/am/AmDemodulatorFactory.java b/src/main/java/io/github/dsheirer/dsp/am/AmDemodulatorFactory.java
index a0734e79..65251127 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/AmDemodulatorFactory.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/AmDemodulatorFactory.java
@@ -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);
diff --git a/src/main/java/io/github/dsheirer/dsp/fm/ISquelchingDemodulator.java b/src/main/java/io/github/dsheirer/dsp/am/ISquelchingDemodulator.java
similarity index 93%
rename from src/main/java/io/github/dsheirer/dsp/fm/ISquelchingDemodulator.java
rename to src/main/java/io/github/dsheirer/dsp/am/ISquelchingDemodulator.java
index 6427affe..b5973b7c 100644
--- a/src/main/java/io/github/dsheirer/dsp/fm/ISquelchingDemodulator.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/ISquelchingDemodulator.java
@@ -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
{
diff --git a/src/main/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulator.java b/src/main/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulator.java
index 6b92daf7..14d6be8a 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulator.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulator.java
@@ -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
{
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())
diff --git a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator128.java b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator128.java
index 7bd7b87d..2105b7eb 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator128.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator128.java
@@ -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 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.
diff --git a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator256.java b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator256.java
index dc050355..fca9c9ea 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator256.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator256.java
@@ -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 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.
diff --git a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator512.java b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator512.java
index c7ed1772..bd6a66f9 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator512.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator512.java
@@ -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 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.
diff --git a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator64.java b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator64.java
index 60cd8fa3..d1585b4a 100644
--- a/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator64.java
+++ b/src/main/java/io/github/dsheirer/dsp/am/VectorAMDemodulator64.java
@@ -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 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.
diff --git a/src/main/java/io/github/dsheirer/dsp/filter/ChannelFilterDesigner.java b/src/main/java/io/github/dsheirer/dsp/filter/ChannelFilterDesigner.java
new file mode 100644
index 00000000..af07ada1
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/dsp/filter/ChannelFilterDesigner.java
@@ -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
+ * ****************************************************************************
+ */
+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));
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/dsp/filter/fir/remez/RemezFIRFilterDesigner.java b/src/main/java/io/github/dsheirer/dsp/filter/fir/remez/RemezFIRFilterDesigner.java
index 54d53119..3f4cac82 100644
--- a/src/main/java/io/github/dsheirer/dsp/filter/fir/remez/RemezFIRFilterDesigner.java
+++ b/src/main/java/io/github/dsheirer/dsp/filter/fir/remez/RemezFIRFilterDesigner.java
@@ -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() + "]" );
diff --git a/src/main/java/io/github/dsheirer/dsp/fm/SquelchingFMDemodulator.java b/src/main/java/io/github/dsheirer/dsp/fm/SquelchingFMDemodulator.java
deleted file mode 100644
index eaee00de..00000000
--- a/src/main/java/io/github/dsheirer/dsp/fm/SquelchingFMDemodulator.java
+++ /dev/null
@@ -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
- * ****************************************************************************
- */
-
-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
-{
- 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 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;
- }
- }
-}
\ No newline at end of file
diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelch.java b/src/main/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelch.java
index ec35e24d..a3dafc82 100644
--- a/src/main/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelch.java
+++ b/src/main/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelch.java
@@ -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
{
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 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
}
/**
- * 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
}
/**
- * 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
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
{
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()));
diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetector.java b/src/main/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetector.java
new file mode 100644
index 00000000..629408dd
--- /dev/null
+++ b/src/main/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetector.java
@@ -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
+ * ****************************************************************************
+ */
+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);
+ }
+}
diff --git a/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
index 4baab168..3801cacc 100644
--- a/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
+++ b/src/main/java/io/github/dsheirer/gui/playlist/channel/AMConfigurationEditor.java
@@ -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)
{
diff --git a/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
index 442a2768..96ba82f5 100644
--- a/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
+++ b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java
@@ -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());
}
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java b/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java
index 71d3145f..3ce40239 100644
--- a/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java
+++ b/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java
@@ -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, 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 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 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);
diff --git a/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java b/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java
index d34a88e3..e38eaaff 100644
--- a/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java
+++ b/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java
@@ -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());
}
diff --git a/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java b/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java
index bf3c3a8c..cc960e74 100644
--- a/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java
+++ b/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java
@@ -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 AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_8_33, BW_15_0, BW_25_0);
+ public static EnumSet 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 FM_BANDWIDTHS = EnumSet.of(BW_7_5, BW_12_5, BW_25_0);
diff --git a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java
index 67dfb5bd..d6ac4826 100644
--- a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java
+++ b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java
@@ -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);
diff --git a/src/main/java/io/github/dsheirer/source/SourceEvent.java b/src/main/java/io/github/dsheirer/source/SourceEvent.java
index e494691f..f897a632 100644
--- a/src/main/java/io/github/dsheirer/source/SourceEvent.java
+++ b/src/main/java/io/github/dsheirer/source/SourceEvent.java
@@ -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
diff --git a/src/main/java/io/github/dsheirer/source/tuner/channel/TunerChannelSource.java b/src/main/java/io/github/dsheirer/source/tuner/channel/TunerChannelSource.java
index 1a61f40b..66cd308f 100644
--- a/src/main/java/io/github/dsheirer/source/tuner/channel/TunerChannelSource.java
+++ b/src/main/java/io/github/dsheirer/source/tuner/channel/TunerChannelSource.java
@@ -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:
diff --git a/src/test/java/io/github/dsheirer/audio/playback/AudioProviderPcmTest.java b/src/test/java/io/github/dsheirer/audio/playback/AudioProviderPcmTest.java
new file mode 100644
index 00000000..64c7774b
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/audio/playback/AudioProviderPcmTest.java
@@ -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
+ * ****************************************************************************
+ */
+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));
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/dsp/am/AmAudioNormalizerTest.java b/src/test/java/io/github/dsheirer/dsp/am/AmAudioNormalizerTest.java
new file mode 100644
index 00000000..83a77870
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/dsp/am/AmAudioNormalizerTest.java
@@ -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
+ * ****************************************************************************
+ */
+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);
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulatorTest.java b/src/test/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulatorTest.java
new file mode 100644
index 00000000..47f29bae
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/dsp/am/SquelchingAMDemodulatorTest.java
@@ -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
+ * ****************************************************************************
+ */
+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]);
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/dsp/filter/ChannelFilterDesignerTest.java b/src/test/java/io/github/dsheirer/dsp/filter/ChannelFilterDesignerTest.java
new file mode 100644
index 00000000..2419fa47
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/dsp/filter/ChannelFilterDesignerTest.java
@@ -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
+ * ****************************************************************************
+ */
+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));
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelchTest.java b/src/test/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelchTest.java
new file mode 100644
index 00000000..a86a2075
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/dsp/squelch/AdaptiveSquelchTest.java
@@ -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
+ * ****************************************************************************
+ */
+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 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());
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetectorTest.java b/src/test/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetectorTest.java
new file mode 100644
index 00000000..f6836268
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/dsp/squelch/VoiceModulationDetectorTest.java
@@ -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
+ * ****************************************************************************
+ */
+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());
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSampleRateTest.java b/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSampleRateTest.java
new file mode 100644
index 00000000..61b807a5
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSampleRateTest.java
@@ -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
+ * ****************************************************************************
+ */
+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 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));
+ }
+}
diff --git a/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSquelchRequestTest.java b/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSquelchRequestTest.java
new file mode 100644
index 00000000..00997b98
--- /dev/null
+++ b/src/test/java/io/github/dsheirer/module/decode/am/AMDecoderSquelchRequestTest.java
@@ -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
+ * ****************************************************************************
+ */
+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 events(DecodeConfigAM config, SourceEvent request)
+ {
+ AMDecoder decoder = new AMDecoder(config);
+ List events = new ArrayList<>();
+ decoder.setSourceEventListener(events::add);
+ decoder.getSourceEventListener().receive(request);
+ return events;
+ }
+
+ @Test
+ void currentThresholdRequestIsAnswered()
+ {
+ DecodeConfigAM config = new DecodeConfigAM();
+ config.setSquelchThreshold(-70);
+ List 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 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());
+ }
+}