diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java index cff8030f..98b4ff7d 100644 --- a/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java +++ b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2025 Dennis Sheirer + * 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 @@ -23,9 +23,11 @@ import io.github.dsheirer.audio.squelch.SquelchState; import io.github.dsheirer.dsp.filter.FilterFactory; import io.github.dsheirer.dsp.filter.fir.real.IRealFilter; import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter; +import io.github.dsheirer.dsp.fm.ScalarFMDemodulator; import io.github.dsheirer.dsp.window.WindowType; import io.github.dsheirer.sample.Listener; import java.util.Arrays; +import java.util.Random; /** * Squelch processor for unfiltered, demodulated audio that operators by high-pass filtering the audio to remove below @@ -47,6 +49,12 @@ public class NoiseSquelch implements INoiseSquelchController public static final float DEFAULT_NOISE_CLOSE_THRESHOLD = 0.19f; public static final float MINIMUM_NOISE_THRESHOLD = 0.1f; public static final float MAXIMUM_NOISE_THRESHOLD = 0.5f; + private static final int HIGH_PASS_FILTER_CUTOFF = 3000; + private static final int HIGH_PASS_FILTER_LENGTH = 31; + //Noise-only variance of a 12.5 kHz channel at 25 kHz, which is the reference that the noise thresholds are set against. + private static final float REFERENCE_NOISE_VARIANCE = 0.298f; + private static final int NOISE_MEASUREMENT_SETTLING_SAMPLES = 512; + private static final long NOISE_MEASUREMENT_SEED = 1L; private float[] mFilteredBuffer = new float[0]; private float[] mAudioBuffer = new float[0]; private float mMeanAccumulator; @@ -227,7 +235,7 @@ public class NoiseSquelch implements INoiseSquelchController */ public void setSampleRate(double sampleRate) { - float[] coefficients = FilterFactory.getHighPass((int)sampleRate, 3000, 31, WindowType.BLACKMAN_HARRIS_7); + float[] coefficients = getHighPassCoefficients(sampleRate); mHighPassFilter = new RealFIRFilter(coefficients); mVarianceWindowSize = (int)(sampleRate * (VARIANCE_CALCULATION_WINDOW_MILLISECONDS / 1000.0)); @@ -235,6 +243,88 @@ public class NoiseSquelch implements INoiseSquelchController mAudioBufferFilterDelay = (int)Math.ceil(coefficients.length / 2.0); } + /** + * Sets the sample rate for the incoming demodulated audio sample stream and normalizes the noise measurement for + * the channel filter applied ahead of the FM demodulator. The demodulated noise variance depends on the ratio of + * the channel bandwidth to the sample rate and, without normalization, a narrow channel can have a noise-only + * variance that is below the noise close threshold, which holds the squelch open. The noise measurement is scaled + * up so that a noise-only channel measures at the reference noise variance. The noise measurement is never scaled + * down, so channels that are already above the reference are unchanged. + * + * @param sampleRate of the sample stream. + * @param channelFilter coefficients of the low-pass channel filter applied ahead of the FM demodulator. + */ + public void setSampleRate(double sampleRate, float[] channelFilter) + { + setSampleRate(sampleRate); + + float gain = (float)Math.sqrt(Math.max(1.0f, REFERENCE_NOISE_VARIANCE / getNoiseVariance(sampleRate, channelFilter))); + float[] coefficients = getHighPassCoefficients(sampleRate); + + for(int x = 0; x < coefficients.length; x++) + { + coefficients[x] *= gain; + } + + mHighPassFilter = new RealFIRFilter(coefficients); + } + + /** + * Creates the high-pass filter that isolates the noise in the demodulated audio. + * @param sampleRate of the demodulated audio. + * @return filter coefficients + */ + private static float[] getHighPassCoefficients(double sampleRate) + { + return FilterFactory.getHighPass((int)sampleRate, HIGH_PASS_FILTER_CUTOFF, HIGH_PASS_FILTER_LENGTH, + WindowType.BLACKMAN_HARRIS_7); + } + + /** + * Measures the variance that the squelch sees when the channel contains only noise, using one second of a + * repeatable sequence of complex noise samples. + * + * @param sampleRate of the channel. + * @param channelFilter coefficients of the low-pass channel filter applied ahead of the FM demodulator. + * @return noise-only variance of the demodulated, high-pass filtered samples. + */ + public static float getNoiseVariance(double sampleRate, float[] channelFilter) + { + int length = (int)sampleRate; + float[] i = new float[length]; + float[] q = new float[length]; + Random random = new Random(NOISE_MEASUREMENT_SEED); + + for(int x = 0; x < length; x++) + { + i[x] = (float)random.nextGaussian(); + q[x] = (float)random.nextGaussian(); + } + + float[] demodulated = new ScalarFMDemodulator().demodulate(new RealFIRFilter(channelFilter.clone()).filter(i), + new RealFIRFilter(channelFilter.clone()).filter(q)); + float[] noise = new RealFIRFilter(getHighPassCoefficients(sampleRate)).filter(demodulated); + + int count = length - NOISE_MEASUREMENT_SETTLING_SAMPLES; + double mean = 0.0; + + for(int x = NOISE_MEASUREMENT_SETTLING_SAMPLES; x < length; x++) + { + mean += noise[x]; + } + + mean /= count; + + double variance = 0.0; + + for(int x = NOISE_MEASUREMENT_SETTLING_SAMPLES; x < length; x++) + { + variance += (noise[x] - mean) * (noise[x] - mean); + } + + return (float)(variance / count); + } + public void process(float[] samples) { float[] filtered = mHighPassFilter.filter(samples); 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 d6ac4826..4b3e42f3 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 @@ -452,10 +452,10 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi 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")); } - mNoiseSquelch.setSampleRate(decimatedSampleRate); - float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth); + mNoiseSquelch.setSampleRate(decimatedSampleRate, coefficients); + mIBasebandFilter = FilterFactory.getRealFilter(coefficients); mQBasebandFilter = FilterFactory.getRealFilter(coefficients); diff --git a/src/test/java/io/github/dsheirer/dsp/squelch/NoiseSquelchTest.java b/src/test/java/io/github/dsheirer/dsp/squelch/NoiseSquelchTest.java new file mode 100644 index 00000000..1502a902 --- /dev/null +++ b/src/test/java/io/github/dsheirer/dsp/squelch/NoiseSquelchTest.java @@ -0,0 +1,127 @@ +/* + * ***************************************************************************** + * 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.assertTrue; + +import io.github.dsheirer.dsp.filter.ChannelFilterDesigner; +import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter; +import io.github.dsheirer.dsp.fm.ScalarFMDemodulator; +import java.util.Arrays; +import java.util.Random; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the noise squelch normalization across channel bandwidths and sample rates. + */ +class NoiseSquelchTest +{ + private static final double[][] CHANNELS = {{7500.0, 25000.0}, {7500.0, 25600.0}, {7500.0, 18750.0}, + {12500.0, 25000.0}, {12500.0, 37500.0}, {12500.0, 48828.125}, {25000.0, 50000.0}}; + private static final int BUFFER_SIZE = 2048; + private static final double SECONDS = 5.0; + private static final double NOISE_LEVEL = 0.01; + + /** + * A noise-only channel must stay squelched with the default thresholds. A 7.5 kHz channel at 25 kHz has a noise + * variance between the default open and close thresholds, which holds the squelch open if it's not normalized. + */ + @Test + void noiseOnlyChannelStaysSquelched() + { + for(double[] channel: CHANNELS) + { + double passed = process(channel[0], channel[1], null); + assertEquals(0.0, passed, 0.01, "Noise passed for bandwidth [" + channel[0] + "] rate [" + channel[1] + "]"); + } + } + + @Test + void signalOpensSquelch() + { + for(double[] channel: CHANNELS) + { + double passed = process(channel[0], channel[1], 12.0); + assertTrue(passed > 0.95, "Signal blocked [" + passed + "] for bandwidth [" + channel[0] + "] rate [" + channel[1] + "]"); + } + } + + /** + * The noise thresholds reference a 12.5 kHz channel at 25 kHz, so that channel must not be rescaled. + */ + @Test + void referenceChannelIsNotRescaled() + { + float[] filter = ChannelFilterDesigner.design(25000.0, 12500.0); + assertEquals(0.298f, NoiseSquelch.getNoiseVariance(25000.0, filter), 0.01f); + } + + /** + * Processes an FM tone in noise, or noise only, through the channel filter, demodulator and noise squelch. + * @param snr of the signal in the channel bandwidth in dB, or null for noise only. + * @return fraction of the audio samples passed by the squelch + */ + private static double process(double bandwidth, double sampleRate, Double snr) + { + Random random = new Random(2); + int length = (int)(sampleRate * SECONDS); + float[] i = new float[length]; + float[] q = new float[length]; + double amplitude = snr == null ? 0.0 : + Math.sqrt(2.0 * NOISE_LEVEL * NOISE_LEVEL * (bandwidth / sampleRate) * Math.pow(10.0, snr / 10.0)); + double phase = 0.0; + + for(int x = 0; x < length; x++) + { + phase += 2.0 * Math.PI * (bandwidth * .2) * Math.sin(2.0 * Math.PI * 1000.0 * x / sampleRate) / sampleRate; + i[x] = (float)(random.nextGaussian() * NOISE_LEVEL + amplitude * Math.cos(phase)); + q[x] = (float)(random.nextGaussian() * NOISE_LEVEL + amplitude * Math.sin(phase)); + } + + float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth); + RealFIRFilter iFilter = new RealFIRFilter(coefficients.clone()); + RealFIRFilter qFilter = new RealFIRFilter(coefficients.clone()); + ScalarFMDemodulator demodulator = new ScalarFMDemodulator(); + NoiseSquelch squelch = new NoiseSquelch(NoiseSquelch.DEFAULT_NOISE_OPEN_THRESHOLD, + NoiseSquelch.DEFAULT_NOISE_CLOSE_THRESHOLD, NoiseSquelch.DEFAULT_HYSTERESIS_OPEN_THRESHOLD, + NoiseSquelch.DEFAULT_HYSTERESIS_CLOSE_THRESHOLD); + squelch.setSampleRate(sampleRate, coefficients); + + long[] passed = {0}; + squelch.setAudioListener(audio -> { + for(float sample: audio) + { + if(sample != 0.0f) + { + passed[0]++; + } + } + }); + + for(int offset = 0; offset + BUFFER_SIZE <= length; offset += BUFFER_SIZE) + { + squelch.process(demodulator.demodulate(iFilter.filter(Arrays.copyOfRange(i, offset, offset + BUFFER_SIZE)), + qFilter.filter(Arrays.copyOfRange(q, offset, offset + BUFFER_SIZE)))); + } + + return (double)passed[0] / length; + } +}