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

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

View File

@@ -0,0 +1,44 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.audio.playback;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
/**
* Unit tests for audio provider sample conversion
*/
class AudioProviderPcmTest
{
@Test
void outOfRangeSamplesAreLimitedInsteadOfWrapping()
{
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.35f));
assertEquals(-Short.MAX_VALUE, AudioProvider.toPcm(-26.0f));
}
@Test
void inRangeSamplesAreScaled()
{
assertEquals(0, AudioProvider.toPcm(0.0f));
assertEquals(16383, AudioProvider.toPcm(0.5f));
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.0f));
}
}

View File

@@ -0,0 +1,121 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the AM audio normalizer
*/
class AmAudioNormalizerTest
{
private static final double SAMPLE_RATE = 25000.0;
private static final float MODULATION = 0.8f;
private static float envelope(float carrier, int sample)
{
return carrier * (1.0f + MODULATION * (float)Math.cos(2.0 * Math.PI * 1000.0 / SAMPLE_RATE * sample));
}
@Test
void outputFollowsModulationDepthAtAnyCarrierLevel()
{
for(float carrier: new float[]{0.0005f, 0.002f, 0.01f, 0.03f, 0.1f})
{
checkOutput(carrier);
}
}
private static void checkOutput(float carrier)
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
double sum = 0.0;
float peak = 0.0f;
int settle = (int)SAMPLE_RATE;
int count = (int)SAMPLE_RATE;
for(int x = 0; x < settle + count; x++)
{
float audio = normalizer.process(envelope(carrier, x));
if(x >= settle)
{
sum += audio;
peak = Math.max(peak, Math.abs(audio));
}
}
assertEquals(0.6f, peak, 0.02f, "Peak");
assertEquals(0.0, sum / count, 0.002, "DC");
}
@Test
void transmissionStartHasNoTransient()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < (int)SAMPLE_RATE; x++)
{
assertTrue(Math.abs(normalizer.process(envelope(0.01f, x))) < 0.8f, "Transient at sample " + x);
}
}
@Test
void noiseAfterCarrierDropIsNotAmplifiedToCarrierLevel()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < (int)SAMPLE_RATE; x++)
{
normalizer.process(envelope(0.01f, x));
}
java.util.Random random = new java.util.Random(1);
float peak = 0.0f;
//Noise is 40 dB below the carrier. Skip the carrier estimate decay.
for(int x = 0; x < (int)(SAMPLE_RATE * 0.5); x++)
{
float audio = normalizer.process(0.0001f * (1.0f + random.nextFloat()));
if(x > (int)(SAMPLE_RATE * 0.2))
{
peak = Math.max(peak, Math.abs(audio));
}
}
assertTrue(peak < 0.05f, "Noise peak " + peak);
}
@Test
void outputIsLimited()
{
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
for(int x = 0; x < 1000; x++)
{
normalizer.process(0.001f);
}
assertTrue(normalizer.process(1.0f) <= 0.95f);
}
}

View File

@@ -0,0 +1,105 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the AM demodulator squelch transitions
*/
class SquelchingAMDemodulatorTest
{
private static final int SAMPLE_RATE = 25000;
private static final int BUFFER = 1024;
private static final double TONE = 1000.0;
/**
* Demodulates silence, then a transmission, then silence.
*/
private static float[] transmission(float carrier)
{
SquelchingAMDemodulator demodulator = new SquelchingAMDemodulator(-78.0f, false, 6000.0);
demodulator.setSampleRate(SAMPLE_RATE);
int buffers = 150;
float[] audio = new float[BUFFER * buffers];
for(int b = 0; b < buffers; b++)
{
float[] i = new float[BUFFER];
float[] q = new float[BUFFER];
if(b >= 30 && b < 100)
{
for(int x = 0; x < BUFFER; x++)
{
int sample = b * BUFFER + x;
i[x] = carrier * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * TONE * sample / SAMPLE_RATE));
}
}
System.arraycopy(demodulator.demodulate(i, q), 0, audio, b * BUFFER, BUFFER);
}
return audio;
}
@Test
void transmissionHasNoClicks()
{
for(float carrier: new float[]{0.001f, 0.01f, 0.1f})
{
float[] audio = transmission(carrier);
float peak = 0.0f;
float step = 0.0f;
for(int x = 1; x < audio.length; x++)
{
peak = Math.max(peak, Math.abs(audio[x]));
step = Math.max(step, Math.abs(audio[x] - audio[x - 1]));
}
//Expected 80% modulated peak is 0.6 and the largest step of the 1 kHz tone is 0.15
assertTrue(peak < 0.7f, "Peak " + peak + " for carrier " + carrier);
assertTrue(step < 0.2f, "Step " + step + " for carrier " + carrier);
}
}
@Test
void audioEndsWithTheTransmission()
{
float[] audio = transmission(0.01f);
int last = 0;
for(int x = 0; x < audio.length; x++)
{
if(audio[x] != 0.0f)
{
last = x;
}
}
//Audio is delayed by 32 ms, so it can't run past the end of the transmission plus the delay.
int end = 100 * BUFFER;
assertTrue(last <= end + SAMPLE_RATE * 0.032, "Last audio sample " + last + " for end at " + end);
assertEquals(0.0f, audio[audio.length - 1]);
}
}

View File

@@ -0,0 +1,98 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.filter;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the channel filter design
*/
class ChannelFilterDesignerTest
{
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 7500.0, 8333.0, 12500.0, 15000.0, 25000.0};
/**
* Each channel filter must be valid across decimated sample rates, which includes rates where the remez designer
* previously produced a filter with a large response spike at half of the sample rate.
*/
@Test
void channelFilterResponseIsValidForDecimatedSampleRates()
{
for(double bandwidth: BANDWIDTHS)
{
for(double sampleRate = bandwidth; sampleRate < 4.0 * bandwidth; sampleRate += bandwidth / 32.0)
{
check(sampleRate, bandwidth);
}
}
}
@Test
void tunerDerivedSampleRates()
{
check(11718.75, 3000.0);
check(19531.25, 5000.0);
check(50000.0, 15000.0);
check(29296.875, 7500.0);
check(48828.125, 12500.0);
check(25000.0, 7500.0);
}
/**
* These sample rates use the fallback filter design, which must keep the same bandwidth as the remez design.
*/
@Test
void fallbackFilterKeepsChannelBandwidth()
{
checkPassBand(11718.75, 3000.0);
checkPassBand(19531.25, 5000.0);
checkPassBand(50000.0, 15000.0);
}
private static void checkPassBand(double sampleRate, double bandwidth)
{
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
double response = ChannelFilterDesigner.getResponseDb(coefficients, bandwidth * .4, sampleRate) -
ChannelFilterDesigner.getResponseDb(coefficients, 0.0, sampleRate);
assertTrue(response > -1.0, "Pass band edge response [" + response + " dB] at sample rate [" + sampleRate + "]");
}
private static void check(double sampleRate, double bandwidth)
{
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
assertTrue(ChannelFilterDesigner.isValidFilterResponse(coefficients, sampleRate, (int)(bandwidth * .4), (int)(bandwidth * .5)),
"Filter response for sample rate [" + sampleRate + "] bandwidth [" + bandwidth + "]");
}
@Test
void responseCheckRejectsSpikeAtNyquist()
{
//Low-pass moving average with an added alternating sequence that peaks at half of the sample rate.
float[] coefficients = new float[31];
for(int x = 0; x < coefficients.length; x++)
{
coefficients[x] = 1.0f / coefficients.length + (x % 2 == 0 ? 0.1f : -0.1f);
}
assertTrue(!ChannelFilterDesigner.isValidFilterResponse(coefficients, 25000.0, 3000, 4000));
}
}

View File

@@ -0,0 +1,387 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.squelch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.source.SourceEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the adaptive squelch
*/
class AdaptiveSquelchTest
{
private static final int SAMPLE_RATE = 25000;
private static final float THRESHOLD_DB = -78.0f;
private static final float NOISE = (float)Math.pow(10.0, -9.0); //-90 dB
private static AdaptiveSquelch squelch(int sampleRate, boolean autoTrack)
{
AdaptiveSquelch squelch = new AdaptiveSquelch(THRESHOLD_DB, autoTrack, 6000.0);
squelch.setSampleRate(sampleRate);
return squelch;
}
private static void process(AdaptiveSquelch squelch, float power, double seconds, int sampleRate)
{
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
squelch.process(power);
}
}
/**
* Seconds of samples at the power level until the audio is disabled and until the squelch closes.
*/
private static double[] closeTimes(float signal, int sampleRate)
{
AdaptiveSquelch squelch = squelch(sampleRate, false);
process(squelch, signal, 0.5, sampleRate);
assertTrue(squelch.isUnmuted());
double audio = -1.0;
for(int x = 0; x < sampleRate; x++)
{
squelch.process(NOISE);
if(audio < 0.0 && !squelch.isAudioEnabled())
{
audio = (double)x / sampleRate;
}
if(squelch.isMuted())
{
return new double[]{audio, (double)x / sampleRate};
}
}
throw new AssertionError("Squelch didn't close");
}
@Test
void closeTimeIsIndependentOfSignalLevelAndSampleRate()
{
for(int sampleRate: new int[]{6250, 25000, 50000})
{
for(float signal: new float[]{1E-7f, 1E-6f, 1E-4f, 1E-2f})
{
double[] times = closeTimes(signal, sampleRate);
assertEquals(0.0175, times[0], 0.0085, "Audio disable " + signal + " at " + sampleRate);
assertEquals(0.1175, times[1], 0.0085, "Close " + signal + " at " + sampleRate);
}
}
}
@Test
void startsMuted()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
assertTrue(squelch.isMuted());
assertFalse(squelch.isAudioEnabled());
}
@Test
void shortNoiseBurstDoesNotOpen()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
process(squelch, NOISE, 0.1, SAMPLE_RATE);
process(squelch, 1E-4f, 0.005, SAMPLE_RATE);
process(squelch, NOISE, 0.1, SAMPLE_RATE);
assertTrue(squelch.isMuted());
}
@Test
void hysteresisHoldsOpenBelowOpenThreshold()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
process(squelch, threshold * 1.2f, 0.1, SAMPLE_RATE);
assertTrue(squelch.isUnmuted());
process(squelch, threshold * 0.6f, 2.0, SAMPLE_RATE); //2 dB below open, above close
assertTrue(squelch.isAudioEnabled());
}
@Test
void fadeShorterThanHangKeepsCallOpen()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
process(squelch, 1E-5f, 0.1, SAMPLE_RATE);
process(squelch, NOISE, 0.06, SAMPLE_RATE);
assertFalse(squelch.isAudioEnabled());
assertTrue(squelch.isUnmuted());
process(squelch, 1E-5f, 0.03, SAMPLE_RATE);
assertTrue(squelch.isAudioEnabled());
}
@Test
void lowFrequencyModulationDoesNotDisableAudio()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
process(squelch, threshold * 2.0f, 0.1, SAMPLE_RATE);
//Carrier 3 dB over the open threshold with 100% modulation at 100 Hz
for(int x = 0; x < SAMPLE_RATE * 2; x++)
{
double envelope = 1.0 + Math.cos(2.0 * Math.PI * 100.0 * x / SAMPLE_RATE);
squelch.process((float)(threshold * 2.0 / 1.5 * envelope * envelope));
assertTrue(squelch.isAudioEnabled(), "Audio disabled at sample " + x);
}
}
/**
* Sample powers with the exponential distribution of complex gaussian noise.
*/
private static void noise(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * power));
}
}
/**
* Sample powers for a steady carrier with noise 20 dB below the carrier
*/
private static void carrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
double sigma = Math.sqrt(power * 0.01 / 2.0);
double amplitude = Math.sqrt(power);
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
double i = amplitude + sigma * random.nextGaussian();
double q = sigma * random.nextGaussian();
squelch.process((float)(i * i + q * q));
}
}
@Test
void thresholdBelowNoiseFloorDoesNotLatchOpen()
{
Random random = new Random(1);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
float noise = (float)Math.pow(10.0, -7.4); //-74 dB is above the -78 dB threshold
for(int x = 0; x < SAMPLE_RATE * 30; x++)
{
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * noise));
assertTrue(squelch.isMuted(), "Opened at sample " + x);
}
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void transmissionOpensOverLearnedNoiseFloor()
{
Random random = new Random(2);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random); //-64 dB
assertTrue(squelch.isAudioEnabled());
noise(squelch, 4E-8f, 0.5, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void userMarginAboveMinimumIsKept()
{
Random random = new Random(3);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB, with a -78 dB threshold
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void autoTrackKeepsDecibelMarginWhenNoiseFloorRises()
{
Random random = new Random(4);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB
//Raise the noise floor 16 dB in steps that stay under the squelch threshold.
for(int step = 1; step <= 8; step++)
{
noise(squelch, 1E-9f * (float)Math.pow(10.0, step * 0.2), 11.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted(), "Opened at step " + step);
}
assertEquals(THRESHOLD_DB + 16.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void steadyCarrierPresentAtStartPassesUntilTimeoutThenMutes()
{
Random random = new Random(5);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
carrier(squelch, 2.5E-7f, 59.0, SAMPLE_RATE, random); //-66 dB
assertTrue(squelch.isAudioEnabled());
carrier(squelch, 2.5E-7f, 3.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
carrier(squelch, 2.5E-7f, 30.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
}
@Test
void steadyCarrierAfterNoisePassesUntilTimeoutThenMutes()
{
Random random = new Random(6);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 59.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
carrier(squelch, 4E-7f, 3.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
}
@Test
void transmissionsAreHeardAfterCarrierTimeoutEnds()
{
Random random = new Random(7);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
}
@Test
void autoTrackDisabledUsesUserThreshold()
{
Random random = new Random(8);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //Noise above the threshold opens the squelch
assertTrue(squelch.isUnmuted());
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.01f);
}
/**
* Requests the threshold and returns the user threshold notification value.
*/
private static double requestThreshold(AdaptiveSquelch squelch, double threshold)
{
List<SourceEvent> events = new ArrayList<>();
squelch.setSourceEventListener(events::add);
squelch.receive(SourceEvent.requestSquelchThreshold(null, threshold));
squelch.setSourceEventListener(null);
return events.stream().filter(event -> event.getEvent() == SourceEvent.Event.NOTIFICATION_SQUELCH_USER_THRESHOLD)
.findFirst().orElseThrow().getValue().doubleValue();
}
@Test
void thresholdChangeDuringTimedOutCarrierIsRelativeToNoiseFloor()
{
Random random = new Random(9);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB with a -68 dB threshold
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random); //-64 dB with a -58 dB threshold
assertEquals(-58.0f, squelch.getSquelchThreshold(), 0.5f);
//One dB up from the displayed threshold saves one dB over the -68 dB threshold, not -57 dB
assertEquals(-67.0, requestThreshold(squelch, squelch.getSquelchThreshold() + 1.0), 0.5);
assertEquals(-57.0f, squelch.getSquelchThreshold(), 0.5f);
//The setting applies when the carrier ends
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
assertEquals(-67.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void thresholdChangeBelowMinimumMarginIsLimited()
{
Random random = new Random(10);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
assertEquals(-68.0, requestThreshold(squelch, -72.0), 0.5);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void thresholdChangeWithAutoTrackDisabledIsUnchanged()
{
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
assertEquals(-72.0, requestThreshold(squelch, -72.0), 0.01);
assertEquals(-72.0f, squelch.getSquelchThreshold(), 0.01f);
}
/**
* Sample powers for a carrier with noise 20 dB below the carrier and bursts of low frequency voice-like modulation.
*/
private static void voiceCarrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
{
double sigma = Math.sqrt(power * 0.01 / 2.0);
double amplitude = Math.sqrt(power);
for(int x = 0; x < (int)(seconds * sampleRate); x++)
{
double time = (double)x / sampleRate;
double modulation = 0.3 * Math.cos(2.0 * Math.PI * 500.0 * time) + 0.2 * Math.cos(2.0 * Math.PI * 800.0 * time);
double i = amplitude * (1.0 + modulation) + sigma * random.nextGaussian();
double q = sigma * random.nextGaussian();
squelch.process((float)(i * i + q * q));
}
}
@Test
void carrierWithVoiceModulationDoesNotTimeOut()
{
Random random = new Random(11);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
voiceCarrier(squelch, 4E-7f, 190.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
}
@Test
void voiceOnTimedOutCarrierReopensSquelch()
{
Random random = new Random(12);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
assertTrue(squelch.isAudioEnabled());
}
@Test
void voiceOnCarrierPresentAtStartReopensSquelch()
{
Random random = new Random(13);
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
assertTrue(squelch.isMuted());
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
assertTrue(squelch.isAudioEnabled());
}
}

View File

@@ -0,0 +1,86 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.dsp.squelch;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the voice modulation detector
*/
class VoiceModulationDetectorTest
{
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 8333.0, 15000.0, 25000.0};
/**
* Fills the detector with a carrier having white envelope noise and optional modulation.
*/
private static boolean analyze(double sampleRate, double bandwidth, double modulation, Random random)
{
int samples = (int)(sampleRate / 2.0);
VoiceModulationDetector detector = new VoiceModulationDetector(sampleRate, bandwidth, samples);
for(int x = 0; x < samples; x++)
{
double time = x / sampleRate;
detector.receive((float)(1.0 + modulation * Math.cos(2.0 * Math.PI * 450.0 * time) + 0.1 * random.nextGaussian()));
}
return detector.analyze();
}
@Test
void flatEnvelopeNoiseIsNotVoice()
{
Random random = new Random(1);
for(double bandwidth: BANDWIDTHS)
{
for(double sampleRate: new double[]{25000.0, 39062.5, 50000.0})
{
for(int x = 0; x < 50; x++)
{
assertFalse(analyze(sampleRate, bandwidth, 0.0, random), "Bandwidth " + bandwidth);
}
}
}
}
@Test
void lowFrequencyModulationIsVoice()
{
Random random = new Random(2);
for(double bandwidth: BANDWIDTHS)
{
assertTrue(analyze(39062.5, bandwidth, 0.2, random), "Bandwidth " + bandwidth);
}
}
@Test
void partialBufferIsNotAnalyzed()
{
VoiceModulationDetector detector = new VoiceModulationDetector(25000.0, 6000.0, 12500);
detector.receive(1.0f);
assertFalse(detector.analyze());
}
}

View File

@@ -0,0 +1,136 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
import io.github.dsheirer.module.decode.analog.DecodeConfigAnalog.Bandwidth;
import io.github.dsheirer.sample.complex.ComplexSamples;
import io.github.dsheirer.source.SourceEvent;
import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the channel sample rates supported by the AM decoder.
*/
class AMDecoderSampleRateTest
{
private static final int BUFFER = 2048;
private static AMDecoder decoder(Bandwidth bandwidth, double sampleRate)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
AMDecoder decoder = new AMDecoder(config);
decoder.getSourceEventListener().receive(SourceEvent.sampleRateChange(sampleRate));
return decoder;
}
@Test
void defaultBandwidthIsSixKilohertz()
{
assertEquals(Bandwidth.BW_6_0, new DecodeConfigAM().getBandwidth());
assertTrue(Bandwidth.BW_6_0.isAM());
}
@Test
void minimumChannelSampleRateIsAtLeastTwiceTheBandwidth()
{
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
assertTrue(config.getChannelSpecification().getMinimumSampleRate() >= 2.0 * bandwidth.getValue(), bandwidth.name());
}
}
/**
* Follows the heterodyne channel source decimation selection for tuner sample rates, including 1.8 and 3.6 MHz which
* previously produced an unsupported 28,125 Hz sample rate for the default 15 kHz bandwidth.
*/
@Test
void heterodyneChannelRatesAreSupported()
{
double[] tunerRates = {1.8E6, 2.0E6, 2.4E6, 2.56E6, 3.0E6, 3.2E6, 3.6E6, 6.0E6, 7.2E6, 8.0E6, 10.0E6, 20.0E6};
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
{
DecodeConfigAM config = new DecodeConfigAM();
config.setBandwidth(bandwidth);
ChannelSpecification specification = config.getChannelSpecification();
for(double tunerRate: tunerRates)
{
int decimation = DecimationFilterFactory.getDecimationRate((int)(tunerRate / specification.getMinimumSampleRate()));
decoder(bandwidth, tunerRate / decimation);
}
}
}
/**
* Polyphase channel rates don't use the channel specification minimum rate.
*/
@Test
void ratesBelowTwiceTheBandwidthDecodeAudio()
{
for(double sampleRate: new double[]{25000.0, 28125.0})
{
AMDecoder decoder = decoder(Bandwidth.BW_15_0, sampleRate);
List<float[]> audio = new ArrayList<>();
decoder.setBufferListener(audio::add);
int sample = 0;
for(int b = 0; b < (int)(sampleRate * 2.0 / BUFFER); b++)
{
float[] i = new float[BUFFER];
float[] q = new float[BUFFER];
for(int x = 0; x < BUFFER; x++)
{
i[x] = 0.01f * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * 1000.0 * sample++ / sampleRate));
}
decoder.receive(new ComplexSamples(i, q, 0L));
}
float peak = 0.0f;
for(float[] buffer: audio.subList(audio.size() / 2, audio.size()))
{
for(float value: buffer)
{
peak = Math.max(peak, Math.abs(value));
}
}
assertEquals(0.6f, peak, 0.05f, "Peak at sample rate " + sampleRate);
}
}
@Test
void rateBelowTheBandwidthIsRejected()
{
assertThrows(IllegalStateException.class, () -> decoder(Bandwidth.BW_25_0, 20000.0));
}
}

View File

@@ -0,0 +1,63 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
package io.github.dsheirer.module.decode.am;
import static org.junit.jupiter.api.Assertions.assertEquals;
import io.github.dsheirer.source.SourceEvent;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the source event requests that the squelch controls use to get the current squelch settings.
*/
class AMDecoderSquelchRequestTest
{
private static List<SourceEvent> events(DecodeConfigAM config, SourceEvent request)
{
AMDecoder decoder = new AMDecoder(config);
List<SourceEvent> events = new ArrayList<>();
decoder.setSourceEventListener(events::add);
decoder.getSourceEventListener().receive(request);
return events;
}
@Test
void currentThresholdRequestIsAnswered()
{
DecodeConfigAM config = new DecodeConfigAM();
config.setSquelchThreshold(-70);
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchThreshold());
assertEquals(1, events.size());
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_THRESHOLD, events.getFirst().getEvent());
assertEquals(-70.0, events.getFirst().getValue().doubleValue(), 0.01);
}
@Test
void currentAutoTrackRequestIsAnswered()
{
DecodeConfigAM config = new DecodeConfigAM();
config.setSquelchAutoTrack(false);
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchAutoTrack());
assertEquals(1, events.size());
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_AUTO_TRACK, events.getFirst().getEvent());
assertEquals(0, events.getFirst().getValue().intValue());
}
}