Compare commits

...

7 Commits

Author SHA1 Message Date
denny
415a7017f9 Extends JTransforms to reduce memory allocation on a per FFT iteration. 2026-11-02 04:35:18 -05:00
Denny Sheirer
0b3ce66cad #2559 User Preference setting to use complementary alias colors when dark mode causes the default alias color (black) to have no contrast on background (#2565)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-02 03:41:35 -05:00
Denny Sheirer
14c77c4926 TornadoVM experiment (#2564)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-02 03:28:44 -05:00
Denny Sheirer
8aadbe4706 #2301 am decoder enhancements. New squelch control and filter enhancements. (#2556)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-01 08:55:34 -05:00
Denny Sheirer
19d18b96b3 #2333 Fixes call audio prioritization (#2555)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-01 07:22:39 -05:00
Denny Sheirer
59e64a73ff #2338 Resolves P25P1MessageAssembler full - can't add additional dibit (#2554)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-01 07:09:43 -05:00
Denny Sheirer
8b338dec2d #2373 DMR stale talker alias (#2553)
Co-authored-by: denny <dennis.sheirer@gmail.com>
2026-11-01 06:47:22 -05:00
52 changed files with 3148 additions and 418 deletions

View File

@@ -35,7 +35,7 @@ import org.slf4j.LoggerFactory;
public abstract class AbstractAudioModule extends Module implements IAudioSegmentProvider, IdentifierUpdateListener
{
private final static Logger mLog = LoggerFactory.getLogger(AbstractAudioModule.class);
public static final long DEFAULT_SEGMENT_AUDIO_SAMPLE_LENGTH = 60 * 8000; // 1 minute @ 8kHz
public static final long DEFAULT_MAX_SEGMENT_LENGTH_MILLIS = 60_000; // 1 minute
public static final int DEFAULT_TIMESLOT = 0;
private final int mMaxSegmentAudioSampleLength;
private Listener<AudioSegment> mAudioSegmentListener;
@@ -51,12 +51,12 @@ public abstract class AbstractAudioModule extends Module implements IAudioSegmen
* Constructs an abstract audio module
*
* @param aliasList for aliasing identifiers
* @param maxSegmentAudioSampleLength in milliseconds
* @param maxSegmentLengthMillis maximum audio segment length in milliseconds
*/
public AbstractAudioModule(AliasList aliasList, int timeslot, long maxSegmentAudioSampleLength)
public AbstractAudioModule(AliasList aliasList, int timeslot, long maxSegmentLengthMillis)
{
mAliasList = aliasList;
mMaxSegmentAudioSampleLength = (int)(maxSegmentAudioSampleLength * 8); //Convert milliseconds to samples
mMaxSegmentAudioSampleLength = (int)(maxSegmentLengthMillis * 8); //Convert milliseconds to samples
mTimeslot = timeslot;
mIdentifierCollection = new MutableIdentifierCollection(getTimeslot());
mIdentifierUpdateNotificationBroadcaster.addListener(mIdentifierCollection);
@@ -67,7 +67,7 @@ public abstract class AbstractAudioModule extends Module implements IAudioSegmen
*/
public AbstractAudioModule(AliasList aliasList)
{
this(aliasList, DEFAULT_TIMESLOT, DEFAULT_SEGMENT_AUDIO_SAMPLE_LENGTH);
this(aliasList, DEFAULT_TIMESLOT, DEFAULT_MAX_SEGMENT_LENGTH_MILLIS);
}
/**

View File

@@ -59,7 +59,7 @@ public abstract class JmbeAudioModule extends AbstractAudioModule implements Lis
public JmbeAudioModule(UserPreferences userPreferences, AliasList aliasList, int timeslot)
{
super(aliasList, timeslot, DEFAULT_SEGMENT_AUDIO_SAMPLE_LENGTH);
super(aliasList, timeslot, DEFAULT_MAX_SEGMENT_LENGTH_MILLIS);
mUserPreferences = userPreferences;
MyEventBus.getGlobalEventBus().register(this);
loadConverter();

View File

@@ -46,6 +46,12 @@ public class AudioChannel implements Listener<IdentifierUpdateNotification>
* sample rate.
*/
public static final int SAMPLES_PER_INTERVAL = 160;
/**
* Quantity of consecutive intervals without new audio (1 second) before an incomplete audio segment is treated as
* stalled and abandoned. An audio segment is delivered once to a single channel, so abandoning a segment that is
* still live loses the rest of the call. This has to tolerate decoder gaps, such as a missed P25 LDU (180 ms).
*/
static final int MAX_STALLED_INTERVALS = 50;
private static final Logger LOGGER = LoggerFactory.getLogger(AudioChannel.class);
private final AudioBuffer mAudioBuffer = new AudioBuffer();
private final Broadcaster<AudioEvent> mAudioEventBroadcaster = new Broadcaster<>();
@@ -225,7 +231,7 @@ public class AudioChannel implements Listener<IdentifierUpdateNotification>
}
//Dispose completed/fully-played audio segments and segments that have stalled/failed to progress
if(mCurrentAudioSegment != null && ((mNoAudioFromSegmentIntervalCount >= 6) ||
if(mCurrentAudioSegment != null && ((mNoAudioFromSegmentIntervalCount >= MAX_STALLED_INTERVALS) ||
(mCurrentAudioSegment.isComplete() && (mCurrentBufferIndex >= mCurrentAudioSegment.getAudioBufferCount()))))
{
disposeCurrentAudioSegment();
@@ -401,6 +407,24 @@ public class AudioChannel implements Listener<IdentifierUpdateNotification>
return mAudioSegmentQueue.isEmpty();
}
/**
* Next audio segment queued for playback, without removing it from the queue.
* @return queued audio segment or null if the queue is empty.
*/
AudioSegment peekQueuedAudioSegment()
{
return mAudioSegmentQueue.peek();
}
/**
* Removes the next audio segment from the queue, transferring it (and its consumer count) to the caller.
* @return queued audio segment or null if the queue is empty.
*/
AudioSegment pollQueuedAudioSegment()
{
return mAudioSegmentQueue.poll();
}
/**
* Schedules the audio segment for playback. The audio segment user count should already be incremented by the
* calling entity.
@@ -475,6 +499,7 @@ public class AudioChannel implements Listener<IdentifierUpdateNotification>
}
mCurrentBufferIndex = -1;
mNoAudioFromSegmentIntervalCount = 0;
}
/**

View File

@@ -173,6 +173,7 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
{
Iterator<AudioSegment> it = mAudioSegments.iterator();
AudioSegment audioSegment;
List<AudioSegment> displaced = new ArrayList<>();
//Remove any audio segments flagged as do not monitor. Don't remove completed segments yet, because
//we want to give them a brief chance at playback. Automatically assign linked audio segments to the
@@ -200,6 +201,16 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
if(audioOutput.isLinkedTo(audioSegment))
{
it.remove();
//Linked audio continues the current call, so it goes ahead of anything queued
AudioSegment queued = audioOutput.pollQueuedAudioSegment();
while(queued != null)
{
displaced.add(queued);
queued = audioOutput.pollQueuedAudioSegment();
}
audioOutput.play(audioSegment);
}
}
@@ -212,6 +223,8 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
}
mAudioSegments.addAll(displaced);
//Sort audio segments by playback priority and assign to empty audio outputs
if(!mAudioSegments.isEmpty() && mAudioOutput != null)
{
@@ -232,6 +245,8 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
}
}
replaceLowerPriorityQueuedSegments(mAudioSegments, mAudioOutput.getAudioProvider().getAudioChannels());
}
finally
{
@@ -255,6 +270,40 @@ public class AudioPlaybackManager implements Listener<AudioSegment>, IAudioContr
}
}
/**
* Swaps audio segments queued on the audio channels for waiting audio segments that have a higher playback priority,
* so that a segment queued behind the current playback can't hold a higher priority segment out of the channel.
* Audio that is currently playing is not interrupted. Displaced segments are returned to the waiting list.
*
* @param waiting audio segments, sorted by playback priority.
* @param audioChannels to evaluate.
*/
static void replaceLowerPriorityQueuedSegments(List<AudioSegment> waiting, List<AudioChannel> audioChannels)
{
for(AudioChannel audioChannel: audioChannels)
{
if(waiting.isEmpty())
{
return;
}
AudioSegment queued = audioChannel.peekQueuedAudioSegment();
if(queued != null &&
waiting.getFirst().monitorPriorityProperty().get() < queued.monitorPriorityProperty().get())
{
//The audio thread may have taken the queued segment since the peek, so only return what we removed.
queued = audioChannel.pollQueuedAudioSegment();
audioChannel.play(waiting.removeFirst());
if(queued != null)
{
waiting.add(queued);
}
}
}
}
public void dispose()
{
MyEventBus.getGlobalEventBus().unregister(this);

View File

@@ -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
*/

View File

@@ -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;

View File

@@ -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]));
}
}

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2024 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -429,7 +429,7 @@ public class ChannelMetadata implements Listener<IdentifierUpdateNotification>,
{
if(identifier.getForm() == Form.TALKER_ALIAS)
{
mTalkerAliasIdentifier = identifier;
mTalkerAliasIdentifier = update.isAdd() ? identifier : null;
}
else
{

View 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);
}
}

View File

@@ -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);

View File

@@ -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
{

View File

@@ -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())

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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));
}
}

View File

@@ -27,7 +27,7 @@ import java.util.Arrays;
import java.util.Queue;
import java.util.concurrent.ArrayBlockingQueue;
import org.apache.commons.math3.util.FastMath;
import org.jtransforms.fft.FloatFFT_1D;
import org.jtransforms.fft.ReusableFloatInverseFFT;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -85,7 +85,7 @@ public class ComplexPolyphaseChannelizerM2 extends AbstractComplexPolyphaseChann
//Sized to process 40 times per second
private IFFTProcessorDispatcher mIFFTProcessorDispatcher = new IFFTProcessorDispatcher(25);
private FloatFFT_1D mFFT;
private ReusableFloatInverseFFT mFFT;
private ChannelDFT mChannelDFT;
private float[] mInlineSamples;
private float[] mInlineFilter;
@@ -453,7 +453,7 @@ public class ComplexPolyphaseChannelizerM2 extends AbstractComplexPolyphaseChann
private void init(float[] coefficients)
{
int channelCount = getChannelCount();
mFFT = new FloatFFT_1D(channelCount);
mFFT = new ReusableFloatInverseFFT(channelCount);
mChannelDFT = ChannelDFTFactory.getChannelDFT(channelCount);
int bufferLength = getSubChannelCount() * mTapsPerChannel;
mSamplesPerBlock = channelCount; //Same as subChannelCount / 2
@@ -505,7 +505,7 @@ public class ComplexPolyphaseChannelizerM2 extends AbstractComplexPolyphaseChann
for(int offset = 0; offset < samples.length; offset += blockLength)
{
//Rotate each of the channels to the correct phase using the IFFT
mFFT.complexInverse(samples, offset, true);
mFFT.complexInverse(samples, offset);
}
}

View File

@@ -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() + "]" );

View File

@@ -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;
}
}
}

View File

@@ -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()));

View File

@@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2025 Dennis Sheirer
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -23,9 +23,11 @@ import io.github.dsheirer.audio.squelch.SquelchState;
import io.github.dsheirer.dsp.filter.FilterFactory;
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.fm.ScalarFMDemodulator;
import io.github.dsheirer.dsp.window.WindowType;
import io.github.dsheirer.sample.Listener;
import java.util.Arrays;
import java.util.Random;
/**
* Squelch processor for unfiltered, demodulated audio that operators by high-pass filtering the audio to remove below
@@ -47,6 +49,12 @@ public class NoiseSquelch implements INoiseSquelchController
public static final float DEFAULT_NOISE_CLOSE_THRESHOLD = 0.19f;
public static final float MINIMUM_NOISE_THRESHOLD = 0.1f;
public static final float MAXIMUM_NOISE_THRESHOLD = 0.5f;
private static final int HIGH_PASS_FILTER_CUTOFF = 3000;
private static final int HIGH_PASS_FILTER_LENGTH = 31;
//Noise-only variance of a 12.5 kHz channel at 25 kHz, which is the reference that the noise thresholds are set against.
private static final float REFERENCE_NOISE_VARIANCE = 0.298f;
private static final int NOISE_MEASUREMENT_SETTLING_SAMPLES = 512;
private static final long NOISE_MEASUREMENT_SEED = 1L;
private float[] mFilteredBuffer = new float[0];
private float[] mAudioBuffer = new float[0];
private float mMeanAccumulator;
@@ -227,7 +235,7 @@ public class NoiseSquelch implements INoiseSquelchController
*/
public void setSampleRate(double sampleRate)
{
float[] coefficients = FilterFactory.getHighPass((int)sampleRate, 3000, 31, WindowType.BLACKMAN_HARRIS_7);
float[] coefficients = getHighPassCoefficients(sampleRate);
mHighPassFilter = new RealFIRFilter(coefficients);
mVarianceWindowSize = (int)(sampleRate * (VARIANCE_CALCULATION_WINDOW_MILLISECONDS / 1000.0));
@@ -235,6 +243,88 @@ public class NoiseSquelch implements INoiseSquelchController
mAudioBufferFilterDelay = (int)Math.ceil(coefficients.length / 2.0);
}
/**
* Sets the sample rate for the incoming demodulated audio sample stream and normalizes the noise measurement for
* the channel filter applied ahead of the FM demodulator. The demodulated noise variance depends on the ratio of
* the channel bandwidth to the sample rate and, without normalization, a narrow channel can have a noise-only
* variance that is below the noise close threshold, which holds the squelch open. The noise measurement is scaled
* up so that a noise-only channel measures at the reference noise variance. The noise measurement is never scaled
* down, so channels that are already above the reference are unchanged.
*
* @param sampleRate of the sample stream.
* @param channelFilter coefficients of the low-pass channel filter applied ahead of the FM demodulator.
*/
public void setSampleRate(double sampleRate, float[] channelFilter)
{
setSampleRate(sampleRate);
float gain = (float)Math.sqrt(Math.max(1.0f, REFERENCE_NOISE_VARIANCE / getNoiseVariance(sampleRate, channelFilter)));
float[] coefficients = getHighPassCoefficients(sampleRate);
for(int x = 0; x < coefficients.length; x++)
{
coefficients[x] *= gain;
}
mHighPassFilter = new RealFIRFilter(coefficients);
}
/**
* Creates the high-pass filter that isolates the noise in the demodulated audio.
* @param sampleRate of the demodulated audio.
* @return filter coefficients
*/
private static float[] getHighPassCoefficients(double sampleRate)
{
return FilterFactory.getHighPass((int)sampleRate, HIGH_PASS_FILTER_CUTOFF, HIGH_PASS_FILTER_LENGTH,
WindowType.BLACKMAN_HARRIS_7);
}
/**
* Measures the variance that the squelch sees when the channel contains only noise, using one second of a
* repeatable sequence of complex noise samples.
*
* @param sampleRate of the channel.
* @param channelFilter coefficients of the low-pass channel filter applied ahead of the FM demodulator.
* @return noise-only variance of the demodulated, high-pass filtered samples.
*/
public static float getNoiseVariance(double sampleRate, float[] channelFilter)
{
int length = (int)sampleRate;
float[] i = new float[length];
float[] q = new float[length];
Random random = new Random(NOISE_MEASUREMENT_SEED);
for(int x = 0; x < length; x++)
{
i[x] = (float)random.nextGaussian();
q[x] = (float)random.nextGaussian();
}
float[] demodulated = new ScalarFMDemodulator().demodulate(new RealFIRFilter(channelFilter.clone()).filter(i),
new RealFIRFilter(channelFilter.clone()).filter(q));
float[] noise = new RealFIRFilter(getHighPassCoefficients(sampleRate)).filter(demodulated);
int count = length - NOISE_MEASUREMENT_SETTLING_SAMPLES;
double mean = 0.0;
for(int x = NOISE_MEASUREMENT_SETTLING_SAMPLES; x < length; x++)
{
mean += noise[x];
}
mean /= count;
double variance = 0.0;
for(int x = NOISE_MEASUREMENT_SETTLING_SAMPLES; x < length; x++)
{
variance += (noise[x] - mean) * (noise[x] - mean);
}
return (float)(variance / count);
}
public void process(float[] samples)
{
float[] filtered = mHighPassFilter.filter(samples);

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

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

View File

@@ -25,6 +25,7 @@ import io.github.dsheirer.preference.application.ApplicationPreference;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
import javafx.scene.control.Separator;
@@ -45,6 +46,7 @@ public class AppearancePreferenceEditor extends HBox
private final ApplicationPreference mApplicationPreference;
private GridPane mEditorPane;
private VBox mThemeRadioGroup;
private CheckBox mComplementaryAliasColorsCheckBox;
private Slider mGuiScaleSlider;
private Label mGuiScaleValueLabel;
@@ -74,6 +76,12 @@ public class AppearancePreferenceEditor extends HBox
mEditorPane.add(new Label("Theme"), 0, row, 3, 1);
mEditorPane.add(getThemeRadioGroup(), 0, ++row, 3, 1);
mEditorPane.add(getComplementaryAliasColorsCheckBox(), 0, ++row, 3, 1);
Label aliasColorHint = new Label("Dark themes only. Replaces alias colors that are hard to read against " +
"the background with a complementary color.");
aliasColorHint.setWrapText(true);
mEditorPane.add(aliasColorHint, 0, ++row, 3, 1);
Separator separator = new Separator(Orientation.HORIZONTAL);
GridPane.setHgrow(separator, Priority.ALWAYS);
@@ -139,6 +147,22 @@ public class AppearancePreferenceEditor extends HBox
return mThemeRadioGroup;
}
/**
* Check box to opt in to replacing hard to read alias colors with a complementary color in dark themes.
*/
private CheckBox getComplementaryAliasColorsCheckBox()
{
if(mComplementaryAliasColorsCheckBox == null)
{
mComplementaryAliasColorsCheckBox = new CheckBox("Use Complementary Alias Colors");
mComplementaryAliasColorsCheckBox.setSelected(mApplicationPreference.isComplementaryAliasColors());
mComplementaryAliasColorsCheckBox.setOnAction(e -> mApplicationPreference
.setComplementaryAliasColors(mComplementaryAliasColorsCheckBox.isSelected()));
}
return mComplementaryAliasColorsCheckBox;
}
/**
* Slider to adjust the global GUI zoom factor. The slider snaps to 10% increments and only
* persists the value when the user releases the slider so we do not thrash a LAF reinstall on

View File

@@ -74,6 +74,7 @@ public class ThemeManager
private volatile UserPreferences mUserPreferences;
private volatile Theme mCurrentTheme = Theme.LIGHT;
private volatile double mCurrentGuiScale = 1.0d;
private volatile boolean mComplementaryAliasColors = false;
/** Data URL of the currently-active per-theme accent stylesheet, or null. */
private volatile String mAccentStylesheetUrl;
@@ -109,6 +110,7 @@ public class ThemeManager
mUserPreferences = userPreferences;
mCurrentTheme = userPreferences.getApplicationPreference().getTheme();
mCurrentGuiScale = userPreferences.getApplicationPreference().getGuiScale();
mComplementaryAliasColors = userPreferences.getApplicationPreference().isComplementaryAliasColors();
if(firstBind)
{
@@ -225,12 +227,12 @@ public class ThemeManager
* Provides the color to render user-selected content (e.g. an alias) against a background in the current theme.
* @param color selected by the user.
* @param background that the color is rendered against.
* @return the color, or a readable alternative when dark mode is active and the color lacks contrast with the
* background.
* @return the color, or a readable alternative when the user enabled complementary alias colors, dark mode is
* active and the color lacks contrast with the background.
*/
public Color getReadableColor(Color color, Color background)
{
return isDarkMode() ? ColorContrast.getReadable(color, background) : color;
return mComplementaryAliasColors && isDarkMode() ? ColorContrast.getReadable(color, background) : color;
}
public void register(Scene scene)
@@ -288,6 +290,20 @@ public class ThemeManager
Theme updated = mUserPreferences.getApplicationPreference().getTheme();
double updatedScale = mUserPreferences.getApplicationPreference().getGuiScale();
boolean updatedComplementary = mUserPreferences.getApplicationPreference().isComplementaryAliasColors();
if(updatedComplementary != mComplementaryAliasColors)
{
mComplementaryAliasColors = updatedComplementary;
//The Swing table renderers look up the alias color each time they paint.
EventQueue.invokeLater(() -> {
for(Window window: Window.getWindows())
{
window.repaint();
}
});
}
if(updated == mCurrentTheme && Math.abs(updatedScale - mCurrentGuiScale) < 1e-6)
{

View File

@@ -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);

View File

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

View File

@@ -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);

View File

@@ -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;
@@ -454,39 +452,9 @@ public class NBFMDecoder extends SquelchControlDecoder implements ISourceEventLi
throw new IllegalStateException(getDecoderType().name() + " demodulator with channel bandwidth [" + mChannelBandwidth + "] requires a channel sample rate of [" + (2.0 * mChannelBandwidth + "] - sample rate of [" + decimatedSampleRate + "] is not supported"));
}
mNoiseSquelch.setSampleRate(decimatedSampleRate);
float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth);
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);
}
mNoiseSquelch.setSampleRate(decimatedSampleRate, coefficients);
mIBasebandFilter = FilterFactory.getRealFilter(coefficients);
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);

View File

@@ -796,6 +796,10 @@ public class P25P1MessageFramer extends C4FMMessageFramer
{
dispatchMessage();
}
else
{
mMessageAssembler = null;
}
}
else
{
@@ -803,6 +807,18 @@ public class P25P1MessageFramer extends C4FMMessageFramer
dispatchDroppedSamples(droppedBitCount);
dispatchMessage();
}
//A multi-block TSBK or PDU reconfigures the assembler for its next block. A place holder that resolved to
//a PDU may already contain those blocks, so dispatch each block that's fully assembled.
while(mMessageAssembler != null && mMessageAssembler.isComplete())
{
dispatchMessage();
}
//The new NID ended the multi-block sequence, so the assembler can't continue into the next frame, which
//would prevent starting the assembler for that frame. The elapsed dibits are reported as sync loss.
mMessageAssembler = null;
mPDUSequence = null;
}
mDibitCounter -= 57;

View File

@@ -37,6 +37,7 @@ public class ApplicationPreference extends Preference
private static final String PREFERENCE_KEY_DARK_MODE = "dark.mode";
private static final String PREFERENCE_KEY_THEME = "ui.theme";
private static final String PREFERENCE_KEY_GUI_SCALE = "ui.gui.scale";
private static final String PREFERENCE_KEY_COMPLEMENTARY_ALIAS_COLORS = "ui.complementary.alias.colors";
/** Hard bounds for the GUI zoom factor. */
public static final double MIN_GUI_SCALE = 0.5d;
@@ -49,6 +50,7 @@ public class ApplicationPreference extends Preference
private Boolean mAutomaticDiagnosticMonitoring;
private Theme mTheme;
private Double mGuiScale;
private Boolean mComplementaryAliasColors;
/**
* Constructs an instance
@@ -167,6 +169,32 @@ public class ApplicationPreference extends Preference
return getTheme().isDark();
}
/**
* Indicates if alias colors that lack contrast with the background are replaced with a complementary color when a
* dark theme is active.
* @return true if enabled. Default is false, so that aliases display the color selected by the user.
*/
public boolean isComplementaryAliasColors()
{
if(mComplementaryAliasColors == null)
{
mComplementaryAliasColors = mPreferences.getBoolean(PREFERENCE_KEY_COMPLEMENTARY_ALIAS_COLORS, false);
}
return mComplementaryAliasColors;
}
/**
* Sets the enabled state for using complementary alias colors in dark themes.
* @param enabled true to replace alias colors that lack contrast with the background.
*/
public void setComplementaryAliasColors(boolean enabled)
{
mComplementaryAliasColors = enabled;
mPreferences.putBoolean(PREFERENCE_KEY_COMPLEMENTARY_ALIAS_COLORS, enabled);
notifyPreferenceUpdated();
}
/**
* Returns the GUI zoom factor. 1.0 = 100% (default). Bounded to
* [{@link #MIN_GUI_SCALE}, {@link #MAX_GUI_SCALE}].

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,156 @@
/*
* *****************************************************************************
* 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 org.jtransforms.fft;
import java.util.ArrayList;
import java.util.List;
/**
* Scaled complex inverse FFT that produces the same values as FloatFFT_1D.complexInverse(samples, offset, true) without
* allocating on each call.
*
* FloatFFT_1D allocates a scratch array of two floats per channel on every mixed-radix transform. This class runs the
* same mixed-radix passes using a single reused scratch array. It's in this package because those passes are package
* private. Sizes that FloatFFT_1D doesn't transform as mixed-radix (powers of two and sizes with other prime factors)
* are delegated to FloatFFT_1D.
*
* Note: this class isn't thread safe because the scratch array is shared across calls.
*/
public class ReusableFloatInverseFFT
{
private static final int[] SUPPORTED_RADIX = {3, 5};
private final FloatFFT_1D mFFT;
private final int mSize;
private final int[] mFactors;
private final float[] mScratch;
private final float mScale;
/**
* Constructs an instance
* @param size of the transform in complex samples
*/
public ReusableFloatInverseFFT(int size)
{
mSize = size;
mFFT = new FloatFFT_1D(size);
mFactors = getFactors(size);
mScratch = mFactors != null ? new float[2 * size] : null;
mScale = 1.0f / size;
}
/**
* Identifies the order of radix passes for the size, matching the order used by FloatFFT_1D.
* @param size of the transform
* @return radix values in pass order, or null if the size should be delegated to FloatFFT_1D.
*/
private static int[] getFactors(int size)
{
if(size < 2 || Integer.bitCount(size) == 1)
{
return null;
}
List<Integer> factors = new ArrayList<>();
int remaining = size;
while(remaining % 4 == 0)
{
factors.add(4);
remaining /= 4;
}
//FloatFFT_1D moves a radix-2 pass to the front
if(remaining % 2 == 0)
{
factors.addFirst(2);
remaining /= 2;
}
for(int radix : SUPPORTED_RADIX)
{
while(remaining % radix == 0)
{
factors.add(radix);
remaining /= radix;
}
}
if(remaining != 1)
{
return null;
}
return factors.stream().mapToInt(Integer::intValue).toArray();
}
/**
* Performs a scaled inverse transform in place.
* @param samples containing interleaved I/Q values
* @param offset to the start of the block in the samples array
*/
public void complexInverse(float[] samples, int offset)
{
if(mFactors == null)
{
mFFT.complexInverse(samples, offset, true);
return;
}
final float[] scratch = mScratch;
final int size = mSize;
boolean resultsInScratch = false;
int subTransforms = 1;
int twiddleOffset = 2 * size;
for(int radix : mFactors)
{
int next = radix * subTransforms;
int stride = 2 * (size / next);
float[] in = resultsInScratch ? scratch : samples;
int inOffset = resultsInScratch ? 0 : offset;
float[] out = resultsInScratch ? samples : scratch;
int outOffset = resultsInScratch ? offset : 0;
switch(radix)
{
case 2 -> mFFT.passf2(stride, subTransforms, in, inOffset, out, outOffset, twiddleOffset, 1);
case 3 -> mFFT.passf3(stride, subTransforms, in, inOffset, out, outOffset, twiddleOffset, 1);
case 4 -> mFFT.passf4(stride, subTransforms, in, inOffset, out, outOffset, twiddleOffset, 1);
default -> mFFT.passf5(stride, subTransforms, in, inOffset, out, outOffset, twiddleOffset, 1);
}
resultsInScratch = !resultsInScratch;
subTransforms = next;
twiddleOffset += (radix - 1) * stride;
}
if(resultsInScratch)
{
System.arraycopy(scratch, 0, samples, offset, 2 * size);
}
final float scale = mScale;
final int end = offset + 2 * size;
for(int x = offset; x < end; x++)
{
samples[x] *= scale;
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* *****************************************************************************
* 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;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.alias.AliasList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Tests audio segment rollover at the maximum audio segment length.
*/
public class AudioSegmentRolloverTest
{
private static final float[] ONE_SECOND_OF_AUDIO = new float[8000];
@Test
public void defaultMaximumSegmentLengthIsOneMinute()
{
AudioModule module = new AudioModule(new AliasList("test"), Collections.emptyList());
List<AudioSegment> segments = new ArrayList<>();
module.setAudioSegmentListener(segments::add);
for(int seconds = 0; seconds < 60; seconds++)
{
module.addAudio(ONE_SECOND_OF_AUDIO);
}
assertEquals(1, segments.size(), "A full minute of audio should fit in a single segment");
module.addAudio(ONE_SECOND_OF_AUDIO);
assertEquals(2, segments.size(), "Audio beyond one minute should roll over to a new segment");
assertEquals(60_000, segments.get(0).getDuration());
assertTrue(segments.get(0).isComplete(), "First segment should be closed");
assertTrue(segments.get(1).isLinkedTo(segments.get(0)), "New segment should link to the first segment");
}
}

View File

@@ -0,0 +1,170 @@
/*
* *****************************************************************************
* 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 static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.github.dsheirer.alias.AliasList;
import io.github.dsheirer.audio.AudioSegment;
import io.github.dsheirer.preference.UserPreferences;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests that a higher priority audio segment isn't displaced by lower priority audio during playback (issue #2333).
*/
public class AudioPlaybackPriorityTest
{
private static final float[] AUDIO = new float[AudioChannel.SAMPLES_PER_INTERVAL];
private AudioChannel mChannel;
@BeforeEach
public void setup()
{
mChannel = new AudioChannel(new UserPreferences(), "TEST");
}
@AfterEach
public void teardown()
{
mChannel.dispose();
}
private static AudioSegment segment(int priority)
{
AudioSegment segment = new AudioSegment(new AliasList("test"), 0);
segment.incrementConsumerCount();
segment.monitorPriorityProperty().set(priority);
segment.addAudio(AUDIO);
return segment;
}
/**
* Pulls audio until the buffered audio (start tone and first frame) is exhausted and the channel is waiting on the
* audio segment for more audio.
*/
private void drainBufferedAudio()
{
while(mChannel.getAudio() != null)
{
//Discard buffered audio
}
}
private void pullIntervals(int count)
{
for(int x = 0; x < count; x++)
{
mChannel.getAudio();
}
}
@Test
public void liveSegmentSurvivesDecoderGapWithLowerPriorityQueued()
{
AudioSegment priority1 = segment(1);
AudioSegment priority2 = segment(2);
mChannel.play(priority1);
mChannel.getAudio();
mChannel.play(priority2);
drainBufferedAudio();
//300 ms without audio exceeds a missed P25 LDU
pullIntervals(15);
assertTrue(mChannel.hasAudioSegment(), "Live segment should still be assigned");
assertSame(priority2, mChannel.peekQueuedAudioSegment(), "Queued lower priority segment should not have started");
priority1.addAudio(AUDIO);
assertTrue(mChannel.getAudio() != null, "Audio arriving after the gap should play");
}
@Test
public void stalledSegmentIsAbandonedSoChannelIsReleased()
{
mChannel.play(segment(1));
mChannel.getAudio();
drainBufferedAudio();
pullIntervals(AudioChannel.MAX_STALLED_INTERVALS + 1);
assertFalse(mChannel.hasAudioSegment(), "Stalled segment should be released");
}
@Test
public void completedSegmentIsReleasedWithoutWaitingForStallTimeout()
{
AudioSegment segment = segment(1);
mChannel.play(segment);
mChannel.getAudio();
segment.completeProperty().set(true);
drainBufferedAudio();
pullIntervals(2);
assertFalse(mChannel.hasAudioSegment(), "Completed segment should be released");
}
@Test
public void higherPriorityWaitingSegmentReplacesLowerPriorityQueuedSegment()
{
AudioSegment priority1 = segment(1);
AudioSegment priority3 = segment(3);
mChannel.play(priority3);
List<AudioSegment> waiting = new ArrayList<>(List.of(priority1));
AudioPlaybackManager.replaceLowerPriorityQueuedSegments(waiting, List.of(mChannel));
assertSame(priority1, mChannel.peekQueuedAudioSegment());
assertEquals(List.of(priority3), waiting, "Displaced segment should return to the waiting list");
}
@Test
public void equalAndLowerPriorityWaitingSegmentsDoNotReplaceQueuedSegment()
{
AudioSegment queued = segment(2);
mChannel.play(queued);
for(int priority: new int[]{2, 3})
{
AudioSegment other = segment(priority);
List<AudioSegment> waiting = new ArrayList<>(List.of(other));
AudioPlaybackManager.replaceLowerPriorityQueuedSegments(waiting, List.of(mChannel));
assertSame(queued, mChannel.peekQueuedAudioSegment());
assertEquals(List.of(other), waiting);
}
}
@Test
public void noReplacementWhenNothingIsQueued()
{
List<AudioSegment> waiting = new ArrayList<>(List.of(segment(1)));
AudioPlaybackManager.replaceLowerPriorityQueuedSegments(waiting, List.of(mChannel));
assertNull(mChannel.peekQueuedAudioSegment());
assertEquals(1, waiting.size());
}
}

View File

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

View File

@@ -0,0 +1,108 @@
/*
* *****************************************************************************
* 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.channel.metadata;
import io.github.dsheirer.identifier.Form;
import io.github.dsheirer.identifier.IdentifierClass;
import io.github.dsheirer.identifier.MutableIdentifierCollection;
import io.github.dsheirer.identifier.Role;
import io.github.dsheirer.identifier.alias.DmrTalkerAliasIdentifier;
import io.github.dsheirer.module.decode.dmr.identifier.DMRRadio;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests channel metadata tracking of the FROM talker alias, driven by a mutable identifier collection in the same
* way that a decoder state's identifier updates reach the channel metadata in production.
*/
public class ChannelMetadataTest
{
private static final int TIMESLOT = 1;
private ChannelMetadata mChannelMetadata;
private MutableIdentifierCollection mIdentifierCollection;
@BeforeEach
public void setup()
{
mChannelMetadata = new ChannelMetadata(null, TIMESLOT);
mIdentifierCollection = new MutableIdentifierCollection(TIMESLOT);
mIdentifierCollection.setIdentifierUpdateListener(mChannelMetadata);
}
/**
* End of a call as performed by the DMR decoder state: remove the alias and then the FROM radio.
*/
private void endCall()
{
mIdentifierCollection.remove(IdentifierClass.USER, Form.TALKER_ALIAS, Role.FROM);
mIdentifierCollection.remove(Role.FROM);
}
@Test
public void talkerAliasIsAvailableWhenAdded()
{
DmrTalkerAliasIdentifier alias = new DmrTalkerAliasIdentifier("ALPHA");
mIdentifierCollection.update(DMRRadio.createFrom(100));
mIdentifierCollection.update(alias);
Assertions.assertTrue(mChannelMetadata.hasTalkerAliasIdentifier());
Assertions.assertEquals(alias, mChannelMetadata.getTalkerAliasIdentifier());
}
@Test
public void talkerAliasIsClearedWhenRemoved()
{
mIdentifierCollection.update(DMRRadio.createFrom(100));
mIdentifierCollection.update(new DmrTalkerAliasIdentifier("ALPHA"));
endCall();
Assertions.assertFalse(mChannelMetadata.hasTalkerAliasIdentifier());
Assertions.assertNull(mChannelMetadata.getTalkerAliasIdentifier());
}
/**
* Issue 2373: a radio without a talker alias must not inherit the alias from the previous call on the timeslot.
*/
@Test
public void talkerAliasDoesNotCarryOverToNextCall()
{
mIdentifierCollection.update(DMRRadio.createFrom(100));
mIdentifierCollection.update(new DmrTalkerAliasIdentifier("ALPHA"));
endCall();
mIdentifierCollection.update(DMRRadio.createFrom(200));
Assertions.assertEquals(200, mChannelMetadata.getFromIdentifier().getValue());
Assertions.assertFalse(mChannelMetadata.hasTalkerAliasIdentifier());
}
@Test
public void talkerAliasIsReplacedWhenUpdated()
{
DmrTalkerAliasIdentifier bravo = new DmrTalkerAliasIdentifier("BRAVO");
mIdentifierCollection.update(DMRRadio.createFrom(100));
mIdentifierCollection.update(new DmrTalkerAliasIdentifier("ALPHA"));
mIdentifierCollection.update(bravo);
Assertions.assertEquals(bravo, mChannelMetadata.getTalkerAliasIdentifier());
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
/*
* *****************************************************************************
* Copyright (C) 2014-2026 Dennis Sheirer
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <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.assertTrue;
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
import io.github.dsheirer.dsp.filter.fir.real.RealFIRFilter;
import io.github.dsheirer.dsp.fm.ScalarFMDemodulator;
import java.util.Arrays;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Unit tests for the noise squelch normalization across channel bandwidths and sample rates.
*/
class NoiseSquelchTest
{
private static final double[][] CHANNELS = {{7500.0, 25000.0}, {7500.0, 25600.0}, {7500.0, 18750.0},
{12500.0, 25000.0}, {12500.0, 37500.0}, {12500.0, 48828.125}, {25000.0, 50000.0}};
private static final int BUFFER_SIZE = 2048;
private static final double SECONDS = 5.0;
private static final double NOISE_LEVEL = 0.01;
/**
* A noise-only channel must stay squelched with the default thresholds. A 7.5 kHz channel at 25 kHz has a noise
* variance between the default open and close thresholds, which holds the squelch open if it's not normalized.
*/
@Test
void noiseOnlyChannelStaysSquelched()
{
for(double[] channel: CHANNELS)
{
double passed = process(channel[0], channel[1], null);
assertEquals(0.0, passed, 0.01, "Noise passed for bandwidth [" + channel[0] + "] rate [" + channel[1] + "]");
}
}
@Test
void signalOpensSquelch()
{
for(double[] channel: CHANNELS)
{
double passed = process(channel[0], channel[1], 12.0);
assertTrue(passed > 0.95, "Signal blocked [" + passed + "] for bandwidth [" + channel[0] + "] rate [" + channel[1] + "]");
}
}
/**
* The noise thresholds reference a 12.5 kHz channel at 25 kHz, so that channel must not be rescaled.
*/
@Test
void referenceChannelIsNotRescaled()
{
float[] filter = ChannelFilterDesigner.design(25000.0, 12500.0);
assertEquals(0.298f, NoiseSquelch.getNoiseVariance(25000.0, filter), 0.01f);
}
/**
* Processes an FM tone in noise, or noise only, through the channel filter, demodulator and noise squelch.
* @param snr of the signal in the channel bandwidth in dB, or null for noise only.
* @return fraction of the audio samples passed by the squelch
*/
private static double process(double bandwidth, double sampleRate, Double snr)
{
Random random = new Random(2);
int length = (int)(sampleRate * SECONDS);
float[] i = new float[length];
float[] q = new float[length];
double amplitude = snr == null ? 0.0 :
Math.sqrt(2.0 * NOISE_LEVEL * NOISE_LEVEL * (bandwidth / sampleRate) * Math.pow(10.0, snr / 10.0));
double phase = 0.0;
for(int x = 0; x < length; x++)
{
phase += 2.0 * Math.PI * (bandwidth * .2) * Math.sin(2.0 * Math.PI * 1000.0 * x / sampleRate) / sampleRate;
i[x] = (float)(random.nextGaussian() * NOISE_LEVEL + amplitude * Math.cos(phase));
q[x] = (float)(random.nextGaussian() * NOISE_LEVEL + amplitude * Math.sin(phase));
}
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
RealFIRFilter iFilter = new RealFIRFilter(coefficients.clone());
RealFIRFilter qFilter = new RealFIRFilter(coefficients.clone());
ScalarFMDemodulator demodulator = new ScalarFMDemodulator();
NoiseSquelch squelch = new NoiseSquelch(NoiseSquelch.DEFAULT_NOISE_OPEN_THRESHOLD,
NoiseSquelch.DEFAULT_NOISE_CLOSE_THRESHOLD, NoiseSquelch.DEFAULT_HYSTERESIS_OPEN_THRESHOLD,
NoiseSquelch.DEFAULT_HYSTERESIS_CLOSE_THRESHOLD);
squelch.setSampleRate(sampleRate, coefficients);
long[] passed = {0};
squelch.setAudioListener(audio -> {
for(float sample: audio)
{
if(sample != 0.0f)
{
passed[0]++;
}
}
});
for(int offset = 0; offset + BUFFER_SIZE <= length; offset += BUFFER_SIZE)
{
squelch.process(demodulator.demodulate(iFilter.filter(Arrays.copyOfRange(i, offset, offset + BUFFER_SIZE)),
qFilter.filter(Arrays.copyOfRange(q, offset, offset + BUFFER_SIZE))));
}
return (double)passed[0] / length;
}
}

View File

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

View File

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

View File

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

View File

@@ -278,7 +278,7 @@ public class P25P1DecoderLSMTest
/**
* Adds the CRC-CCITT checksum, 1/2 rate trellis encodes and interleaves the TSBK into 98 dibits.
*/
private static List<Dibit> encodeTSBK(BitSet tsbk)
static List<Dibit> encodeTSBK(BitSet tsbk)
{
BitSet message = (BitSet)tsbk.clone();
int checksum = 0xFFFF;
@@ -387,7 +387,7 @@ public class P25P1DecoderLSMTest
/**
* Adds the most significant bits first, as dibits.
*/
private static void addBits(List<Dibit> dibits, long value, int bitCount)
static void addBits(List<Dibit> dibits, long value, int bitCount)
{
for(int bit = bitCount - 2; bit >= 0; bit -= 2)
{
@@ -398,7 +398,7 @@ public class P25P1DecoderLSMTest
/**
* Sets the value at the bit offset, most significant bit first.
*/
private static void setValue(BitSet bits, int offset, int bitCount, int value)
static void setValue(BitSet bits, int offset, int bitCount, int value)
{
for(int x = 0; x < bitCount; x++)
{

View File

@@ -0,0 +1,238 @@
/*
* *****************************************************************************
* 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.p25.phase1;
import io.github.dsheirer.dsp.symbol.Dibit;
import io.github.dsheirer.edac.bch.BCH_63_16_23_P25;
import io.github.dsheirer.message.IMessage;
import io.github.dsheirer.module.decode.p25.phase1.message.tsbk.TSBKMessage;
import io.github.dsheirer.module.decode.p25.phase1.sync.P25P1SyncDetector;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* JUnit tests for the P25 Phase 1 message framer recovery from a frame with a failed NID. The failed frame assembles
* as a place holder and the valid NID of the next frame forces completion of that place holder, which must not impact
* assembly of the next frame.
*/
public class P25P1MessageFramerTest
{
private static final int NAC = 0x293;
private static final int TSBK_DUID = 7;
private static final int OPCODE_RFSS_STATUS_BROADCAST = 58;
private static final int PDU_FORMAT_AMBTC = 23;
private static final int FRAME_DIBITS = 180; //Sync, NID, header/single TSBK, status dibits and null padding
private static final int DATA_BLOCK_DIBITS = 108; //98 dibits, 3 status dibits and null padding, to next frame
private static final int SYNC_DIBITS = 24;
private static final int SYNC_FRAMES = 8; //Valid frames preceding the failed frame so that the NAC is tracked
private static final int NEXT_FRAMES = 3; //Valid frames following the failed frame
private final Random mRandom = new Random(1);
/**
* Test: control case with all frames detected.
*/
@Test
void testNoFailedFrame()
{
assertEquals(SYNC_FRAMES + NEXT_FRAMES, process(Collections.emptyList()));
}
/**
* Test: a missed AMBTC PDU, fully assembled as a place holder, dispatches all of its data blocks without the
* 'message under assembly is full' error and without losing the next frame, for each supported block count.
*/
@Test
void testPlaceHolderResolvedAsPDU()
{
for(int blocks = 1; blocks <= 5; blocks++)
{
assertEquals(SYNC_FRAMES + NEXT_FRAMES, process(getPDU(blocks, blocks)), blocks + " data blocks");
}
}
/**
* Test: a missed AMBTC PDU that's truncated by the next frame, where the header indicates more data blocks than
* were assembled, doesn't capture the next frame as data blocks.
*/
@Test
void testPlaceHolderResolvedAsTruncatedPDU()
{
assertEquals(SYNC_FRAMES + NEXT_FRAMES, process(getPDU(3, 1)));
}
/**
* Test: a missed corrupt TSBK, where the failed CRC can't indicate the last block, doesn't capture the next frame
* as the second TSBK of the sequence.
*/
@Test
void testPlaceHolderResolvedAsCorruptTSBK()
{
List<Dibit> noise = new ArrayList<>();
for(int x = 0; x < FRAME_DIBITS; x++)
{
noise.add(Dibit.values()[mRandom.nextInt(4)]);
}
assertEquals(SYNC_FRAMES + NEXT_FRAMES, process(noise));
}
/**
* Processes valid TSBK frames, the frame that fails sync detection, and then more valid TSBK frames.
* @param failedFrame dibits
* @return count of valid TSBK messages dispatched by the framer.
*/
private int process(List<Dibit> failedFrame)
{
int[] validCount = new int[1];
P25P1MessageFramer framer = new P25P1MessageFramer();
framer.setListener((IMessage message) -> {
if(message instanceof TSBKMessage tsbk && tsbk.isValid())
{
validCount[0]++;
}
});
framer.start();
for(int x = 0; x < SYNC_FRAMES; x++)
{
process(framer, getFrame(TSBK_DUID, getTSBK(), FRAME_DIBITS), true);
}
process(framer, failedFrame, false);
for(int x = 0; x < NEXT_FRAMES; x++)
{
process(framer, getFrame(TSBK_DUID, getTSBK(), FRAME_DIBITS), true);
}
//A message is dispatched on the dibit following the message, so flush the final frame
process(framer, Collections.nCopies(FRAME_DIBITS, Dibit.D00_PLUS_1), false);
return validCount[0];
}
/**
* Processes the frame dibits, optionally indicating a sync detection after the final sync pattern dibit.
*/
private static void process(P25P1MessageFramer framer, List<Dibit> dibits, boolean detectSync)
{
for(int x = 0; x < dibits.size(); x++)
{
framer.process(dibits.get(x));
if(detectSync && x == SYNC_DIBITS - 1)
{
framer.syncDetected();
}
}
}
/**
* Creates a single block TSBK with the last block indicator set.
*/
private BitSet getTSBK()
{
BitSet tsbk = new BitSet();
tsbk.set(0);
P25P1DecoderLSMTest.setValue(tsbk, 2, 6, OPCODE_RFSS_STATUS_BROADCAST);
for(int x = 16; x < 80; x++)
{
tsbk.set(x, mRandom.nextBoolean());
}
return tsbk;
}
/**
* Creates an AMBTC PDU frame.
* @param indicatedBlocks count of blocks to follow in the header
* @param actualBlocks count of data blocks in the frame.
*/
private List<Dibit> getPDU(int indicatedBlocks, int actualBlocks)
{
BitSet header = new BitSet();
header.set(2); //Outbound
P25P1DecoderLSMTest.setValue(header, 3, 5, PDU_FORMAT_AMBTC);
P25P1DecoderLSMTest.setValue(header, 49, 7, indicatedBlocks);
List<Dibit> data = getData(header);
for(int x = 0; x < actualBlocks; x++)
{
data.addAll(P25P1DecoderLSMTest.encodeTSBK(getTSBK()));
}
return addStatusDibits(data, FRAME_DIBITS + actualBlocks * DATA_BLOCK_DIBITS);
}
/**
* Creates a frame with the sync, NID, and encoded message.
*/
private static List<Dibit> getFrame(int duid, BitSet message, int length)
{
return addStatusDibits(getData(duid, message), length);
}
private static List<Dibit> getData(BitSet pduHeader)
{
return getData(12, pduHeader);
}
private static List<Dibit> getData(int duid, BitSet message)
{
List<Dibit> data = new ArrayList<>();
P25P1DecoderLSMTest.addBits(data, P25P1SyncDetector.SYNC_PATTERN, 48);
P25P1DecoderLSMTest.addBits(data, BCH_63_16_23_P25.encode(NAC, duid) << 1, 64); //64th (parity) bit is zero
data.addAll(P25P1DecoderLSMTest.encodeTSBK(message));
return data;
}
/**
* Inserts a status dibit at every 36th dibit and null pads the frame to the length.
*/
private static List<Dibit> addStatusDibits(List<Dibit> data, int length)
{
List<Dibit> frame = new ArrayList<>();
int pointer = 0;
for(int x = 1; x <= length; x++)
{
if(x % 36 == 0)
{
frame.add(Dibit.D01_PLUS_3); //Outbound (repeater) status
}
else
{
frame.add(pointer < data.size() ? data.get(pointer++) : Dibit.D00_PLUS_1);
}
}
return frame;
}
}

View File

@@ -0,0 +1,134 @@
/*
* *****************************************************************************
* 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 org.jtransforms.fft;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.lang.management.ManagementFactory;
import java.util.Random;
import org.junit.jupiter.api.Test;
/**
* Tests that the reusable inverse FFT is an exact replacement for FloatFFT_1D.
*/
class ReusableFloatInverseFFTTest
{
private static final int OFFSET = 6;
private static final int LARGEST_SIZE = 5000;
private static float[] noise(int length, Random random)
{
float[] samples = new float[length];
for(int x = 0; x < length; x++)
{
samples[x] = random.nextFloat() * 2.0f - 1.0f;
}
return samples;
}
/**
* Test: output values are bit-identical to FloatFFT_1D for every size, including powers of two and sizes with other
* prime factors, and samples outside of the block aren't changed. Each instance is used twice to show that
* scratch contents don't carry over between calls.
*/
@Test
void matchesFloatFFT1D()
{
Random random = new Random(0xC0FFEEL);
for(int size = 1; size <= LARGEST_SIZE; size += size < 512 ? 1 : 53)
{
ReusableFloatInverseFFT reusable = new ReusableFloatInverseFFT(size);
FloatFFT_1D reference = new FloatFFT_1D(size);
for(int pass = 0; pass < 2; pass++)
{
float[] expected = noise(2 * size + 2 * OFFSET, random);
float[] actual = expected.clone();
reference.complexInverse(expected, OFFSET, true);
reusable.complexInverse(actual, OFFSET);
assertArrayEquals(expected, actual, 0.0f, "Size " + size);
}
}
}
/**
* Test: every channel count from 2 to 4800 with only factors of 2, 3 and 5 matches FloatFFT_1D. These are the only
* sizes the channelizer can select.
*/
@Test
void matchesFloatFFT1DForChannelizerSizes()
{
Random random = new Random(0xBEEFL);
for(int size = 2; size <= 4800; size += 2)
{
int remaining = size;
for(int factor : new int[]{2, 3, 5})
{
while(remaining % factor == 0)
{
remaining /= factor;
}
}
if(remaining == 1)
{
float[] expected = noise(2 * size, random);
float[] actual = expected.clone();
new FloatFFT_1D(size).complexInverse(expected, 0, true);
new ReusableFloatInverseFFT(size).complexInverse(actual, 0);
assertArrayEquals(expected, actual, 0.0f, "Size " + size);
}
}
}
/**
* Test: transforms don't allocate, for a mixed-radix size with an odd pass count and an even pass count.
*/
@Test
void doesNotAllocate()
{
com.sun.management.ThreadMXBean threads = (com.sun.management.ThreadMXBean)ManagementFactory.getThreadMXBean();
long threadId = Thread.currentThread().threadId();
for(int size : new int[]{96, 400})
{
ReusableFloatInverseFFT fft = new ReusableFloatInverseFFT(size);
float[] samples = noise(2 * size, new Random(1L));
fft.complexInverse(samples, 0);
long before = threads.getThreadAllocatedBytes(threadId);
for(int x = 0; x < 1000; x++)
{
fft.complexInverse(samples, 0);
}
long allocated = threads.getThreadAllocatedBytes(threadId) - before;
//FloatFFT_1D would allocate at least 8 bytes per channel, per call. Allow for measurement overhead.
assertEquals(0, allocated / (1000L * size), "Size " + size + " allocated " + allocated + " bytes");
}
}
}