mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-09 15:17:24 -05:00
Co-authored-by: denny <dennis.sheirer@gmail.com>
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
117
src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
Normal file
117
src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
/**
|
||||
* Converts an AM envelope to audio by removing the carrier (DC) and scaling the audio by the carrier level.
|
||||
*
|
||||
* An AM signal's envelope is carrier * (1 + modulation), so using the carrier as the gain reference gives audio that
|
||||
* follows the modulation depth of the transmission, independent of the received signal strength and without the
|
||||
* pumping of an audio peak based AGC.
|
||||
*
|
||||
* The carrier estimate uses a cumulative mean at the start of a transmission to lock quickly and without a transient,
|
||||
* and then transitions to a low-pass filter. The DC removal follows the carrier estimate closely. The gain reference
|
||||
* rises with the carrier but falls slowly so that when the carrier drops, the remaining noise isn't amplified to full
|
||||
* scale before the squelch closes.
|
||||
*/
|
||||
public class AmAudioNormalizer
|
||||
{
|
||||
private static final double CARRIER_CUTOFF_HERTZ = 10.0;
|
||||
private static final double GAIN_DECAY_SECONDS = 2.0;
|
||||
|
||||
/**
|
||||
* Gain reference limit, approximately -80 dBFS, that sets the maximum applied gain.
|
||||
*/
|
||||
private static final float MINIMUM_CARRIER_LEVEL = 1E-4f;
|
||||
|
||||
/**
|
||||
* Audio amplitude produced by 100% modulation.
|
||||
*/
|
||||
private static final float FULL_MODULATION_AMPLITUDE = 0.75f;
|
||||
private static final float MAXIMUM_AMPLITUDE = 0.95f;
|
||||
|
||||
private float mCarrierAlpha;
|
||||
private float mGainDecayAlpha;
|
||||
private int mSettlingSamples;
|
||||
private int mSampleCount;
|
||||
private float mCarrier;
|
||||
private float mGainReference;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
*/
|
||||
public AmAudioNormalizer(double sampleRate)
|
||||
{
|
||||
setSampleRate(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sample rate and resets this normalizer.
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
*/
|
||||
public void setSampleRate(double sampleRate)
|
||||
{
|
||||
mCarrierAlpha = (float)(1.0 - Math.exp(-2.0 * Math.PI * CARRIER_CUTOFF_HERTZ / sampleRate));
|
||||
mGainDecayAlpha = (float)(1.0 - Math.exp(-1.0 / (GAIN_DECAY_SECONDS * sampleRate)));
|
||||
mSettlingSamples = (int)Math.ceil(1.0 / mCarrierAlpha);
|
||||
reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets this normalizer to prepare for a new transmission.
|
||||
*/
|
||||
public void reset()
|
||||
{
|
||||
mSampleCount = 0;
|
||||
mCarrier = 0.0f;
|
||||
mGainReference = 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the envelope sample to an audio sample.
|
||||
* @param envelope sample, unscaled
|
||||
* @return audio sample limited to the audio amplitude range.
|
||||
*/
|
||||
public float process(float envelope)
|
||||
{
|
||||
if(mSampleCount < mSettlingSamples)
|
||||
{
|
||||
mSampleCount++;
|
||||
mCarrier += (envelope - mCarrier) / mSampleCount;
|
||||
mGainReference = mCarrier;
|
||||
}
|
||||
else
|
||||
{
|
||||
mCarrier += (envelope - mCarrier) * mCarrierAlpha;
|
||||
|
||||
if(mCarrier > mGainReference)
|
||||
{
|
||||
mGainReference = mCarrier;
|
||||
}
|
||||
else
|
||||
{
|
||||
mGainReference += (mCarrier - mGainReference) * mGainDecayAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
float audio = (envelope - mCarrier) / Math.max(mGainReference, MINIMUM_CARRIER_LEVEL) * FULL_MODULATION_AMPLITUDE;
|
||||
return Math.clamp(audio, -MAXIMUM_AMPLITUDE, MAXIMUM_AMPLITUDE);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
|
||||
@@ -32,32 +31,63 @@ import io.github.dsheirer.source.SourceEvent;
|
||||
public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
|
||||
{
|
||||
private static final float ZERO = 0.0f;
|
||||
private static final float ENVELOPE_GAIN = 1.0f;
|
||||
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
|
||||
private static final double FADE_SECONDS = 0.005;
|
||||
|
||||
/**
|
||||
* Audio delay, longer than the worst case 25 ms for the squelch to detect loss of signal plus the fade duration, so
|
||||
* that the audio fades out before the carrier drop reaches the audio normalizer, which would otherwise produce a
|
||||
* thump at the end of a transmission.
|
||||
*/
|
||||
private static final double AUDIO_DELAY_SECONDS = 0.032;
|
||||
private final IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
private final IAmDemodulator mAmDemodulator;
|
||||
private final AdaptiveSquelch mAdaptiveSquelch;
|
||||
private final AmAudioNormalizer mAudioNormalizer = new AmAudioNormalizer(DEFAULT_SAMPLE_RATE);
|
||||
private float[] mDelayedEnvelope;
|
||||
private boolean[] mDelayedAudioEnabled;
|
||||
private int mDelayPointer = 0;
|
||||
private float mFadeGain = 0.0f;
|
||||
private float mFadeStep = (float)(1.0 / (DEFAULT_SAMPLE_RATE * FADE_SECONDS));
|
||||
private boolean mSquelchChanged = false;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param gain to apply to the demodulated AM samples (e.g. 500.0f)
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* @param squelchAutoTrack to enable the squelch noise floor auto tracking feature.
|
||||
* @param bandwidth of the channel in hertz
|
||||
*/
|
||||
public SquelchingAMDemodulator(float gain, float alpha, float squelchThreshold, boolean squelchAutoTrack)
|
||||
public SquelchingAMDemodulator(float squelchThreshold, boolean squelchAutoTrack, double bandwidth)
|
||||
{
|
||||
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(gain);
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
|
||||
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(ENVELOPE_GAIN);
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(squelchThreshold, squelchAutoTrack, bandwidth);
|
||||
setAudioDelay(DEFAULT_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate.
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate and for the audio
|
||||
* normalizer timing.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mAdaptiveSquelch.setSampleRate(sampleRate);
|
||||
mAudioNormalizer.setSampleRate(sampleRate);
|
||||
mFadeStep = (float)Math.min(1.0, 1.0 / (sampleRate * FADE_SECONDS));
|
||||
setAudioDelay(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the audio delay buffers.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
private void setAudioDelay(double sampleRate)
|
||||
{
|
||||
int length = (int)Math.ceil(sampleRate * AUDIO_DELAY_SECONDS);
|
||||
mDelayedEnvelope = new float[length];
|
||||
mDelayedAudioEnabled = new boolean[length];
|
||||
mDelayPointer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +102,7 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
|
||||
* Demodulates the complex sample arrays.
|
||||
* @param i inphase sample array
|
||||
* @param q quadrature sample array
|
||||
* @return demodulated AM samples with gain applied.
|
||||
* @return demodulated audio samples, normalized to the carrier level, or zero valued samples while squelched.
|
||||
*/
|
||||
public float[] demodulate(float[] i, float[] q)
|
||||
{
|
||||
@@ -86,9 +116,26 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
|
||||
{
|
||||
mAdaptiveSquelch.process(magnitude[x]);
|
||||
|
||||
if(!(mAdaptiveSquelch.isUnmuted()))
|
||||
boolean audioEnabled = mAdaptiveSquelch.isAudioEnabled();
|
||||
float envelope = mDelayedEnvelope[mDelayPointer];
|
||||
boolean delayedAudioEnabled = mDelayedAudioEnabled[mDelayPointer];
|
||||
mDelayedEnvelope[mDelayPointer] = demodulated[x];
|
||||
mDelayedAudioEnabled[mDelayPointer] = audioEnabled;
|
||||
mDelayPointer = (mDelayPointer + 1) % mDelayedEnvelope.length;
|
||||
|
||||
//Fade in once the delayed audio reaches the squelch open point and fade out as soon as the squelch detects
|
||||
//loss of signal, ahead of the delayed audio.
|
||||
mFadeGain = (audioEnabled && delayedAudioEnabled) ? Math.min(mFadeGain + mFadeStep, 1.0f) :
|
||||
Math.max(mFadeGain - mFadeStep, 0.0f);
|
||||
|
||||
if(mFadeGain > ZERO)
|
||||
{
|
||||
demodulated[x] = mAudioNormalizer.process(envelope) * mFadeGain;
|
||||
}
|
||||
else
|
||||
{
|
||||
demodulated[x] = ZERO;
|
||||
mAudioNormalizer.reset();
|
||||
}
|
||||
|
||||
if(mAdaptiveSquelch.isSquelchChanged())
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator128 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_128;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator256 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_256;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator512 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_512;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator64 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_64;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.filter;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Designs low-pass filters for complex sample channels, with a check of the designed frequency response.
|
||||
*/
|
||||
public class ChannelFilterDesigner
|
||||
{
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelFilterDesigner.class);
|
||||
private static final double MAXIMUM_STOP_BAND_DB = -40.0;
|
||||
private static final double MAXIMUM_PASS_BAND_DB = 1.0;
|
||||
private static final int FILTER_RESPONSE_CHECK_POINTS = 1024;
|
||||
private static final int FALLBACK_FILTER_SIZING_ATTENUATION_DB = 75;
|
||||
private static final int FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB = 150;
|
||||
private static final int FALLBACK_FILTER_ATTENUATION_STEP_DB = 15;
|
||||
|
||||
/**
|
||||
* Designs the low-pass channel filter for complex samples, where the channel bandwidth spans the two-sided
|
||||
* (positive and negative frequency) signal. The stop band ripple provides approximately 46 dB of attenuation.
|
||||
*
|
||||
* The remez designer can converge on a filter that meets the specification at the design grid points but has a large
|
||||
* response spike outside of them, so the response of the designed filter is checked before it's used.
|
||||
*
|
||||
* @param sampleRate of the samples to filter in hertz
|
||||
* @param bandwidth of the channel in hertz
|
||||
* @return filter coefficients
|
||||
*/
|
||||
public static float[] design(double sampleRate, double bandwidth)
|
||||
{
|
||||
int passBandStop = (int)(bandwidth * .4);
|
||||
int stopBandStart = (int)(bandwidth * .5);
|
||||
|
||||
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
|
||||
.sampleRate(sampleRate)
|
||||
.gridDensity(16)
|
||||
.oddLength(true)
|
||||
.passBandCutoff(passBandStop)
|
||||
.passBandAmplitude(1.0)
|
||||
.passBandRipple(0.01)
|
||||
.stopBandStart(stopBandStart)
|
||||
.stopBandAmplitude(0.0)
|
||||
.stopBandRipple(0.005)
|
||||
.build();
|
||||
|
||||
float[] coefficients = null;
|
||||
|
||||
try
|
||||
{
|
||||
coefficients = FilterFactory.getTaps(specification);
|
||||
}
|
||||
catch(FilterDesignException fde)
|
||||
{
|
||||
LOGGER.warn("Couldn't design remez channel filter for sample rate [" + sampleRate +
|
||||
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart + "]");
|
||||
}
|
||||
|
||||
if(coefficients == null || !isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
|
||||
{
|
||||
LOGGER.info("Unable to use remez filter designer for sample rate [" + sampleRate + "] pass band stop [" +
|
||||
passBandStop + "] and stop band start [" + stopBandStart +
|
||||
"] - will proceed using simple low pass filter design");
|
||||
//Center the cutoff so the bandwidth matches the remez design and size the filter to meet the response limits.
|
||||
for(int attenuation = FALLBACK_FILTER_SIZING_ATTENUATION_DB; attenuation <= FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB;
|
||||
attenuation += FALLBACK_FILTER_ATTENUATION_STEP_DB)
|
||||
{
|
||||
int length = FilterFactory.getLowPass(sampleRate, passBandStop, stopBandStart, attenuation,
|
||||
WindowType.HAMMING, true).length;
|
||||
coefficients = FilterFactory.getLowPass(sampleRate, (passBandStop + stopBandStart) / 2, length, WindowType.HAMMING);
|
||||
|
||||
if(isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coefficients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the frequency response of the low-pass filter, relative to the response at DC.
|
||||
* @param coefficients of the filter
|
||||
* @param sampleRate of the filter in hertz
|
||||
* @param passBandStop frequency in hertz
|
||||
* @param stopBandStart frequency in hertz
|
||||
* @return true if the pass band and stop band responses are within limits, through to half the sample rate.
|
||||
*/
|
||||
public static boolean isValidFilterResponse(float[] coefficients, double sampleRate, int passBandStop, int stopBandStart)
|
||||
{
|
||||
double dc = getResponseDb(coefficients, 0.0, sampleRate);
|
||||
double nyquist = sampleRate / 2.0;
|
||||
|
||||
for(int x = 0; x <= FILTER_RESPONSE_CHECK_POINTS; x++)
|
||||
{
|
||||
double frequency = nyquist * x / FILTER_RESPONSE_CHECK_POINTS;
|
||||
double response = getResponseDb(coefficients, frequency, sampleRate) - dc;
|
||||
|
||||
if((frequency <= passBandStop && response > MAXIMUM_PASS_BAND_DB) ||
|
||||
(frequency >= stopBandStart && response > MAXIMUM_STOP_BAND_DB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the magnitude response of the filter at the specified frequency.
|
||||
*/
|
||||
public static double getResponseDb(float[] coefficients, double frequency, double sampleRate)
|
||||
{
|
||||
double radians = 2.0 * Math.PI * frequency / sampleRate;
|
||||
double real = 0.0;
|
||||
double imaginary = 0.0;
|
||||
|
||||
for(int x = 0; x < coefficients.length; x++)
|
||||
{
|
||||
real += coefficients[x] * Math.cos(radians * x);
|
||||
imaginary += coefficients[x] * Math.sin(radians * x);
|
||||
}
|
||||
|
||||
return 20.0 * Math.log10(Math.hypot(real, imaginary));
|
||||
}
|
||||
}
|
||||
@@ -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() + "]" );
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.dsp.fm;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* FM Demodulator for demodulating complex samples and producing demodulated floating point samples.
|
||||
*
|
||||
* Implements listener of source events to process runtime squelch threshold change request events
|
||||
* which are forwarded to the power squelch control.
|
||||
*/
|
||||
public class SquelchingFMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(SquelchingFMDemodulator.class);
|
||||
private static final float ZERO = 0.0f;
|
||||
private final AdaptiveSquelch mAdaptiveSquelch;
|
||||
private final IMagnitudeCalculator mMagnitude = MagnitudeFactory.getMagnitudeCalculator();
|
||||
private final IDemodulator mFmDemodulator = FmDemodulatorFactory.getFmDemodulator();
|
||||
private boolean mSquelchChanged = false;
|
||||
|
||||
/**
|
||||
* Creates an FM demodulator instance with a default gain of 1.0.
|
||||
*
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* causes immediate mute and unmute. Set to higher count to prevent mute/unmute flapping.
|
||||
* @param squelchAutoTrack to enable the squelch noise floor auto-tracking feature.
|
||||
*/
|
||||
public SquelchingFMDemodulator(float alpha, float squelchThreshold, boolean squelchAutoTrack)
|
||||
{
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mAdaptiveSquelch.setSampleRate(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the listener to receive notifications of squelch change events from the power squelch.
|
||||
*/
|
||||
public void setSourceEventListener(Listener<SourceEvent> listener)
|
||||
{
|
||||
mAdaptiveSquelch.setSourceEventListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demodulates the complex (I/Q) sample arrays
|
||||
* @param i inphase samples
|
||||
* @param q quadrature samples
|
||||
* @return demodulated real samples
|
||||
*/
|
||||
@Override
|
||||
public float[] demodulate(float[] i, float[] q)
|
||||
{
|
||||
setSquelchChanged(false);
|
||||
|
||||
float[] demodulated = mFmDemodulator.demodulate(i, q);
|
||||
float[] magnitude = mMagnitude.calculate(i, q);
|
||||
|
||||
for(int x = 0; x < magnitude.length; x++)
|
||||
{
|
||||
mAdaptiveSquelch.process(magnitude[x]);
|
||||
|
||||
if(!(mAdaptiveSquelch.isUnmuted()))
|
||||
{
|
||||
demodulated[x] = ZERO;
|
||||
}
|
||||
|
||||
if(mAdaptiveSquelch.isSquelchChanged())
|
||||
{
|
||||
setSquelchChanged(true);
|
||||
}
|
||||
}
|
||||
|
||||
return demodulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the threshold for squelch control
|
||||
* @param threshold (dB)
|
||||
*/
|
||||
public void setSquelchThreshold(float threshold)
|
||||
{
|
||||
mAdaptiveSquelch.setSquelchThreshold(threshold);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSquelchAutoTrack(boolean autoTrack)
|
||||
{
|
||||
mAdaptiveSquelch.setSquelchAutoTrack(autoTrack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the squelch state has changed during the processing of buffer(s)
|
||||
*/
|
||||
public boolean isSquelchChanged()
|
||||
{
|
||||
return mSquelchChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or resets the squelch changed flag.
|
||||
*/
|
||||
private void setSquelchChanged(boolean changed)
|
||||
{
|
||||
mSquelchChanged = changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the squelch state is currently muted
|
||||
*/
|
||||
public boolean isMuted()
|
||||
{
|
||||
return mAdaptiveSquelch.isMuted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(SourceEvent sourceEvent)
|
||||
{
|
||||
switch(sourceEvent.getEvent())
|
||||
{
|
||||
//Only forward squelch threshold and auto-track current & change requests
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
case REQUEST_CURRENT_SQUELCH_AUTO_TRACK:
|
||||
case REQUEST_CHANGE_SQUELCH_AUTO_TRACK:
|
||||
mAdaptiveSquelch.receive(sourceEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,66 +19,106 @@
|
||||
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.iir.SinglePoleIirFilter;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import org.apache.commons.math3.util.FastMath;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Adaptive squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
* Adaptive power squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
*
|
||||
* Signal power is measured in 5 millisecond blocks so that all timing is independent of the sample rate. The squelch
|
||||
* opens after consecutive blocks at or above the threshold. It closes on a 20 millisecond power average that spans at
|
||||
* least one cycle of the lowest voice frequencies, so that modulation doesn't look like a loss of signal, and which
|
||||
* responds in a fixed time regardless of how strong the signal was.
|
||||
*
|
||||
* Closing occurs in two steps. Audio is disabled as soon as the power average falls below the close threshold, which
|
||||
* limits the noise tail, while the squelch stays open for a hang period to bridge a brief signal fade without ending
|
||||
* the call.
|
||||
*
|
||||
* When auto-track is enabled, the noise floor is the lowest power from the recent intervals that look like noise, using
|
||||
* the ratio mean(power^2) / mean(power)^2, which is 2.0 for gaussian noise and approaches 1.0 for a steady carrier. The
|
||||
* threshold is held at a margin over the noise floor, so a threshold below the noise floor can't latch the squelch open.
|
||||
* The squelch stays closed until the first noise floor measurement is complete. A signal that doesn't look like noise
|
||||
* is excluded from the noise floor unless it persists for the carrier timeout, after which it's treated as the noise
|
||||
* floor so that a dead carrier doesn't hold the squelch open. The noise floor recovers when the carrier ends. A carrier
|
||||
* with voice modulation, such as a continuous broadcast, doesn't time out, and a timed out carrier that develops voice
|
||||
* modulation is removed from the noise floor.
|
||||
*
|
||||
* Initially modeled on: https://github.com/gnuradio/gnuradio/blob/master/gr-analog/lib/simple_squelch_cc_impl.cc
|
||||
*/
|
||||
public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AdaptiveSquelch.class);
|
||||
private static final long AUTO_ADJUST_PERIOD_MS = Duration.ofSeconds(5).toMillis();
|
||||
private SinglePoleIirFilter mFilter;
|
||||
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
|
||||
private static final double BLOCK_SECONDS = 0.005;
|
||||
private static final int OPEN_BLOCKS = 2; //10 milliseconds
|
||||
private static final int CLOSE_AVERAGE_BLOCKS = 4; //20 milliseconds
|
||||
private static final int HANG_BLOCKS = 20; //100 milliseconds
|
||||
private static final int POWER_INTERVAL_BLOCKS = 100; //500 milliseconds
|
||||
private static final int NOISE_WINDOW_INTERVALS = 20; //10 seconds
|
||||
private static final int CARRIER_TIMEOUT_INTERVALS = 120; //60 seconds
|
||||
private static final int MINIMUM_VOICE_INTERVALS = 3;
|
||||
private static final double NOISE_POWER_RATIO = 1.85;
|
||||
private static final float MINIMUM_SQUELCH_MARGIN = 4.0f; //6 dB
|
||||
private static final float CLOSE_THRESHOLD_RATIO = 0.5f; //3 dB below the open threshold
|
||||
private final float[] mCloseBlocks = new float[CLOSE_AVERAGE_BLOCKS];
|
||||
private int mCloseBlockPointer = 0;
|
||||
private float mCloseAccumulator = 0.0f;
|
||||
private int mBlockSize = (int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS);
|
||||
private int mBlockSampleCount = 0;
|
||||
private float mBlockAccumulator = 0.0f;
|
||||
private int mOpenCount = 0;
|
||||
private int mHangCount = 0;
|
||||
private float mPower = 0.0f;
|
||||
private boolean mSquelch;
|
||||
private float mSquelchThreshold;
|
||||
private double mIntervalPower = 0.0;
|
||||
private double mIntervalPowerSquared = 0.0;
|
||||
private int mIntervalSampleCount = 0;
|
||||
private int mIntervalBlockCount = 0;
|
||||
private boolean mFirstIntervalComplete = false;
|
||||
private final float[] mNoiseHistory = new float[NOISE_WINDOW_INTERVALS];
|
||||
private int mNoiseHistoryPointer = 0;
|
||||
private int mNoiseHistoryCount = 0;
|
||||
private int mCarrierIntervalCount = 0;
|
||||
private int mVoiceIntervalCount = 0;
|
||||
private boolean mCarrierTimedOut = false;
|
||||
private boolean mCarrierDetected = false;
|
||||
private Float mNoiseFloorBeforeCarrier;
|
||||
private final VoiceModulationDetector mVoiceModulationDetector;
|
||||
private Float mNoiseFloor;
|
||||
private float mSquelchBuffer;
|
||||
private Float mLowestNoiseFloor;
|
||||
private float mUserThreshold;
|
||||
private boolean mSquelch = true;
|
||||
private float mSquelchThreshold;
|
||||
private boolean mSquelchChanged = false;
|
||||
private int mPowerLevelBroadcastCount = 0;
|
||||
private int mPowerLevelBroadcastThreshold;
|
||||
private boolean mAutoTrackNoiseFloor;
|
||||
private long mLastAutoAdjustTimestamp;
|
||||
private Listener<SourceEvent> mSourceEventListener;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
* Testing against a 12.5 kHz analog FM modulated signal, the following parameters provided a
|
||||
* good responsiveness. A threshold of -80.0 dB seemed to trigger significant flapping during un-squelching.
|
||||
* - alpha: 0.0001
|
||||
* - threshold: 78.0 dB
|
||||
*
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* @param autoTrack the noise floor when squelched and periodically readjust the squelch threshold.
|
||||
* @param bandwidth of the channel in hertz
|
||||
*/
|
||||
public AdaptiveSquelch(float alpha, float squelchThreshold, boolean autoTrack)
|
||||
public AdaptiveSquelch(float squelchThreshold, boolean autoTrack, double bandwidth)
|
||||
{
|
||||
mFilter = new SinglePoleIirFilter(alpha);
|
||||
mVoiceModulationDetector = new VoiceModulationDetector(DEFAULT_SAMPLE_RATE, bandwidth,
|
||||
(int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS) * POWER_INTERVAL_BLOCKS);
|
||||
setSquelchThreshold(squelchThreshold);
|
||||
mAutoTrackNoiseFloor = autoTrack;
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
mPowerLevelBroadcastThreshold = 25000; //Based on a default sample rate of 50 kHz, so 2x/second
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sample rate to effect the frequency of power level notifications where the notifications are
|
||||
* sent twice a second.
|
||||
* Sets the sample rate to size the power measurement blocks.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mPowerLevelBroadcastThreshold = sampleRate / 2;
|
||||
mBlockSize = Math.max(1, (int)Math.round(sampleRate * BLOCK_SECONDS));
|
||||
mVoiceModulationDetector.configure(sampleRate, mBlockSize * POWER_INTERVAL_BLOCKS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,32 +172,36 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold
|
||||
* Sets the squelch threshold. With auto-track enabled, the threshold is treated as a margin over the current noise
|
||||
* floor, subject to the minimum squelch margin. The margin is stored relative to the lowest observed noise floor so
|
||||
* that a change made while a timed out carrier has raised the noise floor applies once that carrier ends.
|
||||
* @param squelchThreshold in decibels
|
||||
*/
|
||||
public void setSquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
float squelchThresholdDb = fromDb(squelchThreshold);
|
||||
setSquelchThreshold(squelchThresholdDb, true);
|
||||
float threshold = fromDb(squelchThreshold);
|
||||
|
||||
if(mAutoTrackNoiseFloor && mNoiseFloor != null)
|
||||
{
|
||||
mUserThreshold = mLowestNoiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, threshold / mNoiseFloor);
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
else
|
||||
{
|
||||
mUserThreshold = threshold;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* Applies the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* and auto-track changes made by the stream processing thread.
|
||||
*
|
||||
* @param squelchThreshold value
|
||||
* @param resetNoiseFloor to reset the noise floor and squelch threshold
|
||||
*/
|
||||
private synchronized void setSquelchThreshold(float squelchThreshold, boolean resetNoiseFloor)
|
||||
private synchronized void applySquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
mSquelchThreshold = squelchThreshold;
|
||||
|
||||
if(resetNoiseFloor)
|
||||
{
|
||||
//Set noise floor to null so that both the noise floor and the squelch buffer get updated after first power reading
|
||||
mNoiseFloor = null;
|
||||
}
|
||||
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
}
|
||||
|
||||
@@ -183,59 +227,231 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a complex IQ sample and changes squelch state when the signal power is above or below the
|
||||
* threshold value.
|
||||
* Processes a complex IQ sample and updates the squelch state at the end of each power measurement block.
|
||||
* @param magnitude of a complex sample (inphase * inphase + quadrature * quadrature)
|
||||
*/
|
||||
public void process(float magnitude)
|
||||
{
|
||||
mPower = mFilter.filter(magnitude);
|
||||
mBlockAccumulator += magnitude;
|
||||
mBlockSampleCount++;
|
||||
mIntervalPower += magnitude;
|
||||
mIntervalPowerSquared += (double)magnitude * magnitude;
|
||||
mIntervalSampleCount++;
|
||||
|
||||
if(mSquelch && mPower >= mSquelchThreshold)
|
||||
//Voice detection is only needed for a carrier, so that channels with only noise don't incur this cost.
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
setSquelch(false);
|
||||
}
|
||||
else if(!mSquelch && mPower < mSquelchThreshold)
|
||||
{
|
||||
setSquelch(true);
|
||||
mVoiceModulationDetector.receive((float)Math.sqrt(magnitude));
|
||||
}
|
||||
|
||||
mPowerLevelBroadcastCount++;
|
||||
if(mPowerLevelBroadcastCount % mPowerLevelBroadcastThreshold == 0)
|
||||
if(mBlockSampleCount >= mBlockSize)
|
||||
{
|
||||
mPowerLevelBroadcastCount = 0;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
adjustSquelchThreshold();
|
||||
}
|
||||
float power = mBlockAccumulator / mBlockSampleCount;
|
||||
mBlockAccumulator = 0.0f;
|
||||
mBlockSampleCount = 0;
|
||||
processBlock(power);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With the user specified squelch threshold, establishes a squelch buffer (delta) from the actual noise floor and
|
||||
* then automatically updates the squelch threshold to be the current noise floor plus this threshold value.
|
||||
*
|
||||
* Note: this method should only be invoked from a squelch (true) state.
|
||||
* Updates the squelch state from a block power measurement.
|
||||
* @param power of the block
|
||||
*/
|
||||
private void adjustSquelchThreshold()
|
||||
private void processBlock(float power)
|
||||
{
|
||||
if(mAutoTrackNoiseFloor)
|
||||
mCloseAccumulator += power - mCloseBlocks[mCloseBlockPointer];
|
||||
mCloseBlocks[mCloseBlockPointer] = power;
|
||||
mCloseBlockPointer = (mCloseBlockPointer + 1) % CLOSE_AVERAGE_BLOCKS;
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
if(mNoiseFloor == null)
|
||||
//With auto-track, the threshold isn't reliable until there's a noise floor measurement.
|
||||
boolean ready = mFirstIntervalComplete || !mAutoTrackNoiseFloor;
|
||||
mOpenCount = (ready && power >= mSquelchThreshold) ? mOpenCount + 1 : 0;
|
||||
|
||||
if(mOpenCount >= OPEN_BLOCKS)
|
||||
{
|
||||
//We set the squelch buffer just once, after the squelch threshold is set and noise floor is set to null
|
||||
mSquelchBuffer = mSquelchThreshold - mPower;
|
||||
mOpenCount = 0;
|
||||
mHangCount = 0;
|
||||
//Seed the close average so that it doesn't start with the blocks from before the signal arrived.
|
||||
Arrays.fill(mCloseBlocks, power);
|
||||
mCloseAccumulator = power * CLOSE_AVERAGE_BLOCKS;
|
||||
setSquelch(false);
|
||||
}
|
||||
}
|
||||
else if(mCloseAccumulator / CLOSE_AVERAGE_BLOCKS >= mSquelchThreshold * CLOSE_THRESHOLD_RATIO)
|
||||
{
|
||||
mHangCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
mHangCount++;
|
||||
|
||||
if(mHangCount >= HANG_BLOCKS)
|
||||
{
|
||||
setSquelch(true);
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts power blocks to provide periodic power level notifications and noise floor updates.
|
||||
*/
|
||||
private void updatePowerInterval()
|
||||
{
|
||||
mIntervalBlockCount++;
|
||||
|
||||
if(mIntervalBlockCount >= POWER_INTERVAL_BLOCKS)
|
||||
{
|
||||
double mean = mIntervalPower / mIntervalSampleCount;
|
||||
mPower = (float)mean;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mAutoTrackNoiseFloor && mean > 0.0)
|
||||
{
|
||||
updateNoiseFloor(mIntervalPowerSquared / mIntervalSampleCount / (mean * mean));
|
||||
}
|
||||
|
||||
mNoiseFloor = mPower;
|
||||
mFirstIntervalComplete = true;
|
||||
mIntervalPower = 0.0;
|
||||
mIntervalPowerSquared = 0.0;
|
||||
mIntervalSampleCount = 0;
|
||||
mIntervalBlockCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if((System.currentTimeMillis() - mLastAutoAdjustTimestamp) > AUTO_ADJUST_PERIOD_MS)
|
||||
{
|
||||
setSquelchThreshold(mNoiseFloor + mSquelchBuffer, false);
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
/**
|
||||
* Updates the noise floor history from the current interval power and updates the squelch threshold.
|
||||
* @param powerRatio mean(power^2) / mean(power)^2 for the interval
|
||||
*/
|
||||
private void updateNoiseFloor(double powerRatio)
|
||||
{
|
||||
boolean voice = mVoiceModulationDetector.analyze();
|
||||
mCarrierDetected = powerRatio < NOISE_POWER_RATIO;
|
||||
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
updateCarrier(voice);
|
||||
}
|
||||
else
|
||||
{
|
||||
resetCarrierTracking();
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mNoiseHistoryCount > 0)
|
||||
{
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the carrier timeout from an interval that doesn't look like noise. A carrier that lasts for the timeout
|
||||
* period is treated as the noise floor, unless it has voice modulation.
|
||||
* @param voice true if voice modulation was detected in this interval
|
||||
*/
|
||||
private void updateCarrier(boolean voice)
|
||||
{
|
||||
mCarrierIntervalCount++;
|
||||
|
||||
if(voice)
|
||||
{
|
||||
mVoiceIntervalCount++;
|
||||
}
|
||||
|
||||
boolean hasVoice = mVoiceIntervalCount >= MINIMUM_VOICE_INTERVALS;
|
||||
|
||||
if(mCarrierTimedOut && hasVoice)
|
||||
{
|
||||
restoreNoiseFloor();
|
||||
resetCarrierTracking();
|
||||
}
|
||||
else if(mCarrierTimedOut)
|
||||
{
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
else if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS && !hasVoice)
|
||||
{
|
||||
//Replace the history so that the carrier takes effect immediately.
|
||||
mCarrierTimedOut = true;
|
||||
mNoiseFloorBeforeCarrier = mNoiseFloor;
|
||||
mNoiseHistoryCount = 0;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS)
|
||||
{
|
||||
resetCarrierTracking();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the counts for the current carrier evaluation period.
|
||||
*/
|
||||
private void resetCarrierTracking()
|
||||
{
|
||||
mCarrierIntervalCount = 0;
|
||||
mVoiceIntervalCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a timed out carrier from the noise floor, restoring the noise floor from before the carrier. If there's
|
||||
* no noise floor from before the carrier, the user threshold is restored until the noise floor can be measured.
|
||||
*/
|
||||
private void restoreNoiseFloor()
|
||||
{
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseHistoryCount = 0;
|
||||
|
||||
if(mNoiseFloorBeforeCarrier != null)
|
||||
{
|
||||
addNoisePower(mNoiseFloorBeforeCarrier);
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
mNoiseFloor = null;
|
||||
mLowestNoiseFloor = null;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the power to the noise history.
|
||||
* @param power of the interval
|
||||
*/
|
||||
private void addNoisePower(float power)
|
||||
{
|
||||
mNoiseHistory[mNoiseHistoryPointer] = power;
|
||||
mNoiseHistoryPointer = (mNoiseHistoryPointer + 1) % NOISE_WINDOW_INTERVALS;
|
||||
mNoiseHistoryCount = Math.min(mNoiseHistoryCount + 1, NOISE_WINDOW_INTERVALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold to the margin over the current noise floor, where the margin is the larger of the
|
||||
* minimum margin and the amount that the user threshold exceeds the lowest observed noise floor.
|
||||
*/
|
||||
private void updateTrackedThreshold()
|
||||
{
|
||||
float noiseFloor = Float.MAX_VALUE;
|
||||
|
||||
for(int x = 0; x < mNoiseHistoryCount; x++)
|
||||
{
|
||||
int index = (mNoiseHistoryPointer - 1 - x + NOISE_WINDOW_INTERVALS) % NOISE_WINDOW_INTERVALS;
|
||||
noiseFloor = Math.min(noiseFloor, mNoiseHistory[index]);
|
||||
}
|
||||
|
||||
mNoiseFloor = noiseFloor;
|
||||
mLowestNoiseFloor = (mLowestNoiseFloor == null) ? noiseFloor : Math.min(mLowestNoiseFloor, noiseFloor);
|
||||
float threshold = noiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, mUserThreshold / mLowestNoiseFloor);
|
||||
|
||||
if(threshold != mSquelchThreshold)
|
||||
{
|
||||
applySquelchThreshold(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +471,15 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
return !mSquelch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if audio should be passed. This is false during the hang period, while the signal is below the close
|
||||
* threshold but the squelch is still open.
|
||||
*/
|
||||
public boolean isAudioEnabled()
|
||||
{
|
||||
return !mSquelch && mHangCount == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current power level
|
||||
* @return current power level in dB
|
||||
@@ -309,6 +534,7 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
setSquelchThreshold(sourceEvent.getValue().floatValue());
|
||||
broadcast(SourceEvent.squelchUserThreshold(toDb(mUserThreshold)));
|
||||
break;
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import org.jtransforms.fft.FloatFFT_1D;
|
||||
|
||||
/**
|
||||
* Detects voice modulation on the envelope of a steady AM carrier. The noise on a carrier's envelope is spectrally flat
|
||||
* across the channel, while voice energy is concentrated in the lower part of the voice band. The detector compares
|
||||
* the average spectral density of a low and a high band, with band edges limited to the channel pass band.
|
||||
*
|
||||
* A single detection isn't proof of a voice transmission and a pause in speech isn't proof of a dead carrier, so the
|
||||
* results should be evaluated across a longer period.
|
||||
*/
|
||||
public class VoiceModulationDetector
|
||||
{
|
||||
private static final float MAXIMUM_FREQUENCY = 2300.0f;
|
||||
private static final float MAXIMUM_FREQUENCY_BANDWIDTH_RATIO = 0.38f;
|
||||
private static final float LOW_BAND_START = 300.0f;
|
||||
private static final float LOW_BAND_STOP_RATIO = 0.52f;
|
||||
private static final float HIGH_BAND_START_RATIO = 0.65f;
|
||||
private static final double DETECTION_THRESHOLD_DB = 5.0;
|
||||
|
||||
private final double mBandwidth;
|
||||
private FloatFFT_1D mFFT;
|
||||
private float[] mEnvelope;
|
||||
private float[] mWindow;
|
||||
private int mLowStartBin;
|
||||
private int mLowStopBin;
|
||||
private int mHighStartBin;
|
||||
private int mHighStopBin;
|
||||
private int mPointer = 0;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
* @param bandwidth of the channel in hertz, spanning positive and negative frequencies
|
||||
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
|
||||
*/
|
||||
public VoiceModulationDetector(double sampleRate, double bandwidth, int analysisSamples)
|
||||
{
|
||||
mBandwidth = bandwidth;
|
||||
configure(sampleRate, analysisSamples);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sample rate and resets this detector.
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
|
||||
*/
|
||||
public final void configure(double sampleRate, int analysisSamples)
|
||||
{
|
||||
int length = Integer.highestOneBit(analysisSamples);
|
||||
mFFT = new FloatFFT_1D(length);
|
||||
mEnvelope = new float[length];
|
||||
mWindow = new float[length];
|
||||
|
||||
for(int x = 0; x < length; x++)
|
||||
{
|
||||
mWindow[x] = (float)(0.5 - 0.5 * Math.cos(2.0 * Math.PI * x / length));
|
||||
}
|
||||
|
||||
float stop = Math.min(MAXIMUM_FREQUENCY, (float)mBandwidth * MAXIMUM_FREQUENCY_BANDWIDTH_RATIO);
|
||||
double hertzPerBin = sampleRate / length;
|
||||
mLowStartBin = (int)Math.ceil(LOW_BAND_START / hertzPerBin);
|
||||
mLowStopBin = (int)(stop * LOW_BAND_STOP_RATIO / hertzPerBin);
|
||||
mHighStartBin = (int)Math.ceil(stop * HIGH_BAND_START_RATIO / hertzPerBin);
|
||||
mHighStopBin = (int)(stop / hertzPerBin);
|
||||
reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards any partial analysis buffer.
|
||||
*/
|
||||
public void reset()
|
||||
{
|
||||
mPointer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an envelope sample to the analysis buffer. Samples are ignored once the buffer is full.
|
||||
* @param envelope sample
|
||||
*/
|
||||
public void receive(float envelope)
|
||||
{
|
||||
if(mPointer < mEnvelope.length)
|
||||
{
|
||||
mEnvelope[mPointer++] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes the buffered samples and resets this detector.
|
||||
* @return true if the analysis buffer is full and the samples contain voice modulation.
|
||||
*/
|
||||
public boolean analyze()
|
||||
{
|
||||
boolean full = mPointer == mEnvelope.length;
|
||||
mPointer = 0;
|
||||
|
||||
if(!full)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float mean = 0.0f;
|
||||
|
||||
for(float sample: mEnvelope)
|
||||
{
|
||||
mean += sample;
|
||||
}
|
||||
|
||||
mean /= mEnvelope.length;
|
||||
|
||||
for(int x = 0; x < mEnvelope.length; x++)
|
||||
{
|
||||
mEnvelope[x] = (mEnvelope[x] - mean) * mWindow[x];
|
||||
}
|
||||
|
||||
mFFT.realForward(mEnvelope);
|
||||
double low = getBandPower(mLowStartBin, mLowStopBin);
|
||||
double high = getBandPower(mHighStartBin, mHighStopBin);
|
||||
return high > 0.0 && 10.0 * Math.log10(low / high) >= DETECTION_THRESHOLD_DB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the mean power of the bins from the real transform output of interleaved real and imaginary values.
|
||||
*/
|
||||
private double getBandPower(int startBin, int stopBin)
|
||||
{
|
||||
double power = 0.0;
|
||||
|
||||
for(int bin = startBin; bin < stopBin; bin++)
|
||||
{
|
||||
double real = mEnvelope[2 * bin];
|
||||
double imaginary = mEnvelope[2 * bin + 1];
|
||||
power += real * real + imaginary * imaginary;
|
||||
}
|
||||
|
||||
return power / Math.max(1, stopBin - startBin);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,14 @@ package io.github.dsheirer.module.decode.am;
|
||||
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||
import io.github.dsheirer.channel.state.IDecoderStateEventProvider;
|
||||
import io.github.dsheirer.channel.state.State;
|
||||
import io.github.dsheirer.dsp.am.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.am.SquelchingAMDemodulator;
|
||||
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
|
||||
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
|
||||
import io.github.dsheirer.dsp.filter.resample.RealResampler;
|
||||
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.gain.AudioGainAndDcFilter;
|
||||
import io.github.dsheirer.dsp.squelch.INoiseSquelchController;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelch;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.module.decode.DecoderType;
|
||||
import io.github.dsheirer.module.decode.SquelchControlDecoder;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
@@ -52,15 +46,9 @@ import org.slf4j.LoggerFactory;
|
||||
*/
|
||||
public class AMDecoder extends SquelchControlDecoder implements ISourceEventListener, ISourceEventProvider,
|
||||
IComplexSamplesListener, Listener<ComplexSamples>, IRealBufferProvider,
|
||||
IDecoderStateEventProvider, INoiseSquelchController
|
||||
IDecoderStateEventProvider
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AMDecoder.class);
|
||||
private static final float DEMODULATOR_GAIN = 150.0f;
|
||||
private static final float SQUELCH_ALPHA_DECAY = 0.0004f;
|
||||
private static final float MINIMUM_GAIN = 0.5f;
|
||||
private static final float MAXIMUM_GAIN = 16.0f;
|
||||
private static final float OBJECTIVE_AUDIO_AMPLITUDE = 0.75f;
|
||||
private AudioGainAndDcFilter mAGC = new AudioGainAndDcFilter(MINIMUM_GAIN, MAXIMUM_GAIN, OBJECTIVE_AUDIO_AMPLITUDE);
|
||||
private static final double DEMODULATED_AUDIO_SAMPLE_RATE = 8000.0;
|
||||
private IRealFilter mIBasebandFilter;
|
||||
private IRealFilter mQBasebandFilter;
|
||||
@@ -73,7 +61,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
private Listener<DecoderStateEvent> mDecoderStateEventListener;
|
||||
private final double mChannelBandwidth;
|
||||
protected boolean mSquelch = true;
|
||||
private final NoiseSquelch mNoiseSquelch = new NoiseSquelch(0.1f, 0.2f, 4, 6);
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
@@ -83,8 +70,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
public AMDecoder(DecodeConfigAM config)
|
||||
{
|
||||
super(config);
|
||||
mDemodulator = new SquelchingAMDemodulator(DEMODULATOR_GAIN, SQUELCH_ALPHA_DECAY, config.getSquelchThreshold(),
|
||||
config.isSquelchAutoTrack());
|
||||
mDemodulator = new SquelchingAMDemodulator(config.getSquelchThreshold(), config.isSquelchAutoTrack(),
|
||||
config.getBandwidth().getValue());
|
||||
mChannelBandwidth = config.getBandwidth().getValue();
|
||||
}
|
||||
|
||||
@@ -94,30 +81,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
return DecoderType.AM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSquelchOverride(boolean override)
|
||||
{
|
||||
mNoiseSquelch.setSquelchOverride(override);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHysteresisThreshold(int open, int close)
|
||||
{
|
||||
mNoiseSquelch.setHysteresisThreshold(open, close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setNoiseThreshold(float open, float close)
|
||||
{
|
||||
mNoiseSquelch.setNoiseThreshold(open, close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setNoiseSquelchStateListener(Listener<NoiseSquelchState> listener)
|
||||
{
|
||||
mNoiseSquelch.setNoiseSquelchStateListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the ISourceEventListener interface
|
||||
*/
|
||||
@@ -154,8 +117,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
if(mResampledBufferListener != null)
|
||||
{
|
||||
mResampledBufferListener.receive(demodulatedSamples);
|
||||
// //Apply audio gain and rebroadcast
|
||||
// super.broadcast(mAGC.process(demodulatedSamples));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +157,7 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
{
|
||||
if(mIDecimationFilter == null || mQDecimationFilter == null)
|
||||
{
|
||||
throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source " +
|
||||
throw new IllegalStateException("AM demodulator module must receive a sample rate change source " +
|
||||
"event before it can process complex sample buffers");
|
||||
}
|
||||
|
||||
@@ -208,8 +169,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
|
||||
float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ);
|
||||
|
||||
mNoiseSquelch.process(demodulated);
|
||||
|
||||
if(mResampler != null)
|
||||
{
|
||||
//Squelch changed while processing this audio buffer
|
||||
@@ -225,6 +184,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
//Demodulator says to mute and we are unmuted = Squelch and End Call
|
||||
else
|
||||
{
|
||||
//Process the final audio and flush the resampler so that audio doesn't carry into the next call
|
||||
mResampler.resample(demodulated, true);
|
||||
mSquelch = true;
|
||||
notifyCallEnd();
|
||||
}
|
||||
@@ -275,7 +236,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
*/
|
||||
protected void notifyCallStart()
|
||||
{
|
||||
mAGC.reset();
|
||||
broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0));
|
||||
}
|
||||
|
||||
@@ -398,53 +358,17 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
|
||||
mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
|
||||
|
||||
if((decimatedSampleRate < (2.0 * mChannelBandwidth)))
|
||||
//The complex sample rate must span the two-sided channel bandwidth.
|
||||
if(decimatedSampleRate < mChannelBandwidth)
|
||||
{
|
||||
throw new IllegalStateException(getDecoderType().name() + " demodulator with channel bandwidth [" +
|
||||
mChannelBandwidth + "] requires a channel sample rate of [" + (2.0 * mChannelBandwidth +
|
||||
"] - sample rate of [" + decimatedSampleRate + "] is not supported"));
|
||||
mChannelBandwidth + "] requires a channel sample rate of at least [" + mChannelBandwidth +
|
||||
"] - sample rate of [" + decimatedSampleRate + "] is not supported");
|
||||
}
|
||||
|
||||
mDemodulator.setSampleRate((int) decimatedSampleRate);
|
||||
|
||||
mNoiseSquelch.setSampleRate(decimatedSampleRate);
|
||||
|
||||
int passBandStop = (int) (mChannelBandwidth * .8);
|
||||
int stopBandStart = (int) mChannelBandwidth;
|
||||
|
||||
float[] coefficients = null;
|
||||
|
||||
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
|
||||
.sampleRate(decimatedSampleRate * 2)
|
||||
.gridDensity(16)
|
||||
.oddLength(true)
|
||||
.passBandCutoff(passBandStop)
|
||||
.passBandAmplitude(1.0)
|
||||
.passBandRipple(0.01)
|
||||
.stopBandStart(stopBandStart)
|
||||
.stopBandAmplitude(0.0)
|
||||
.stopBandRipple(0.005) //Approximately 90 dB attenuation
|
||||
.build();
|
||||
|
||||
try
|
||||
{
|
||||
coefficients = FilterFactory.getTaps(specification);
|
||||
}
|
||||
catch(FilterDesignException fde)
|
||||
{
|
||||
mLog.error("Couldn't design demodulator remez filter for sample rate [" + sampleRate +
|
||||
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart +
|
||||
"] - will proceed using sinc (low-pass) filter");
|
||||
}
|
||||
|
||||
if(coefficients == null)
|
||||
{
|
||||
mLog.info("Unable to use remez filter designer for sample rate [" + decimatedSampleRate +
|
||||
"] pass band stop [" + passBandStop +
|
||||
"] and stop band start [" + stopBandStart + "] - will proceed using simple low pass filter design");
|
||||
coefficients = FilterFactory.getLowPass(decimatedSampleRate, passBandStop, stopBandStart, 60,
|
||||
WindowType.HAMMING, true);
|
||||
}
|
||||
float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth);
|
||||
|
||||
mIBasebandFilter = FilterFactory.getRealFilter(coefficients);
|
||||
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
|
||||
{
|
||||
BW_3_0("3.0 kHz", 3000.0),
|
||||
BW_5_0("5.0 kHz", 5000.0),
|
||||
BW_6_0("6.0 kHz", 6000.0),
|
||||
BW_7_5("7.5 kHz", 7500.0),
|
||||
BW_8_33("8.33 kHz", 8333.0),
|
||||
BW_12_5("12.5 kHz", 12500.0),
|
||||
@@ -121,7 +122,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
|
||||
}
|
||||
|
||||
//AM demodulator channel bandwidth options
|
||||
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_8_33, BW_15_0, BW_25_0);
|
||||
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_6_0, BW_8_33, BW_15_0, BW_25_0);
|
||||
|
||||
//FM demodulator channel bandwidth options
|
||||
public static EnumSet<Bandwidth> FM_BANDWIDTHS = EnumSet.of(BW_7_5, BW_12_5, BW_25_0);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user