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()); + } +}