mirror of
https://github.com/DSheirer/sdrtrunk.git
synced 2026-11-12 08:37:25 -05:00
Compare commits
7 Commits
2373-stale
...
jtransform
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
415a7017f9 | ||
|
|
0b3ce66cad | ||
|
|
14c77c4926 | ||
|
|
8aadbe4706 | ||
|
|
19d18b96b3 | ||
|
|
59e64a73ff | ||
|
|
8b338dec2d |
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,6 +30,16 @@ import javax.sound.sampled.AudioFormat;
|
||||
*/
|
||||
public abstract class AudioProvider
|
||||
{
|
||||
/**
|
||||
* Converts the audio sample to a 16-bit PCM sample, limiting out of range values instead of allowing them to wrap.
|
||||
* @param sample in range -1.0 to 1.0
|
||||
* @return PCM sample
|
||||
*/
|
||||
protected static short toPcm(float sample)
|
||||
{
|
||||
return (short)(Math.clamp(sample, -1.0f, 1.0f) * Short.MAX_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio format for this provider
|
||||
*/
|
||||
|
||||
@@ -77,7 +77,7 @@ public class AudioProviderMono extends AudioProvider
|
||||
|
||||
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
|
||||
{
|
||||
shortBuffer.put((short) (audio[x] * Short.MAX_VALUE));
|
||||
shortBuffer.put(toPcm(audio[x]));
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
@@ -108,7 +108,7 @@ public class AudioProviderMultiChannel extends AudioProvider
|
||||
{
|
||||
for(int channelNumber = 0; channelNumber < mAudioChannels.size(); channelNumber++)
|
||||
{
|
||||
shortBuffer.put((short)(samples[channelNumber][sampleCounter] * Short.MAX_VALUE));
|
||||
shortBuffer.put(toPcm(samples[channelNumber][sampleCounter]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ public class AudioProviderStereo extends AudioProvider
|
||||
|
||||
for(int x = 0; x < AudioChannel.SAMPLES_PER_INTERVAL; x++)
|
||||
{
|
||||
shortBuffer.put((short)(left[x] * Short.MAX_VALUE));
|
||||
shortBuffer.put((short)(right[x] * Short.MAX_VALUE));
|
||||
shortBuffer.put(toPcm(left[x]));
|
||||
shortBuffer.put(toPcm(right[x]));
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
117
src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
Normal file
117
src/main/java/io/github/dsheirer/dsp/am/AmAudioNormalizer.java
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
/**
|
||||
* Converts an AM envelope to audio by removing the carrier (DC) and scaling the audio by the carrier level.
|
||||
*
|
||||
* An AM signal's envelope is carrier * (1 + modulation), so using the carrier as the gain reference gives audio that
|
||||
* follows the modulation depth of the transmission, independent of the received signal strength and without the
|
||||
* pumping of an audio peak based AGC.
|
||||
*
|
||||
* The carrier estimate uses a cumulative mean at the start of a transmission to lock quickly and without a transient,
|
||||
* and then transitions to a low-pass filter. The DC removal follows the carrier estimate closely. The gain reference
|
||||
* rises with the carrier but falls slowly so that when the carrier drops, the remaining noise isn't amplified to full
|
||||
* scale before the squelch closes.
|
||||
*/
|
||||
public class AmAudioNormalizer
|
||||
{
|
||||
private static final double CARRIER_CUTOFF_HERTZ = 10.0;
|
||||
private static final double GAIN_DECAY_SECONDS = 2.0;
|
||||
|
||||
/**
|
||||
* Gain reference limit, approximately -80 dBFS, that sets the maximum applied gain.
|
||||
*/
|
||||
private static final float MINIMUM_CARRIER_LEVEL = 1E-4f;
|
||||
|
||||
/**
|
||||
* Audio amplitude produced by 100% modulation.
|
||||
*/
|
||||
private static final float FULL_MODULATION_AMPLITUDE = 0.75f;
|
||||
private static final float MAXIMUM_AMPLITUDE = 0.95f;
|
||||
|
||||
private float mCarrierAlpha;
|
||||
private float mGainDecayAlpha;
|
||||
private int mSettlingSamples;
|
||||
private int mSampleCount;
|
||||
private float mCarrier;
|
||||
private float mGainReference;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
*/
|
||||
public AmAudioNormalizer(double sampleRate)
|
||||
{
|
||||
setSampleRate(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sample rate and resets this normalizer.
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
*/
|
||||
public void setSampleRate(double sampleRate)
|
||||
{
|
||||
mCarrierAlpha = (float)(1.0 - Math.exp(-2.0 * Math.PI * CARRIER_CUTOFF_HERTZ / sampleRate));
|
||||
mGainDecayAlpha = (float)(1.0 - Math.exp(-1.0 / (GAIN_DECAY_SECONDS * sampleRate)));
|
||||
mSettlingSamples = (int)Math.ceil(1.0 / mCarrierAlpha);
|
||||
reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets this normalizer to prepare for a new transmission.
|
||||
*/
|
||||
public void reset()
|
||||
{
|
||||
mSampleCount = 0;
|
||||
mCarrier = 0.0f;
|
||||
mGainReference = 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the envelope sample to an audio sample.
|
||||
* @param envelope sample, unscaled
|
||||
* @return audio sample limited to the audio amplitude range.
|
||||
*/
|
||||
public float process(float envelope)
|
||||
{
|
||||
if(mSampleCount < mSettlingSamples)
|
||||
{
|
||||
mSampleCount++;
|
||||
mCarrier += (envelope - mCarrier) / mSampleCount;
|
||||
mGainReference = mCarrier;
|
||||
}
|
||||
else
|
||||
{
|
||||
mCarrier += (envelope - mCarrier) * mCarrierAlpha;
|
||||
|
||||
if(mCarrier > mGainReference)
|
||||
{
|
||||
mGainReference = mCarrier;
|
||||
}
|
||||
else
|
||||
{
|
||||
mGainReference += (mCarrier - mGainReference) * mGainDecayAlpha;
|
||||
}
|
||||
}
|
||||
|
||||
float audio = (envelope - mCarrier) / Math.max(mGainReference, MINIMUM_CARRIER_LEVEL) * FULL_MODULATION_AMPLITUDE;
|
||||
return Math.clamp(audio, -MAXIMUM_AMPLITUDE, MAXIMUM_AMPLITUDE);
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,6 @@ public class AmDemodulatorFactory
|
||||
return new VectorAMDemodulator256(gain);
|
||||
case VECTOR_SIMD_512:
|
||||
return new VectorAMDemodulator512(gain);
|
||||
case VECTOR_SIMD_PREFERRED:
|
||||
return new VectorAMDemodulator64(gain);
|
||||
case SCALAR:
|
||||
case UNCALIBRATED:
|
||||
return new ScalarAMDemodulator(gain);
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.dsp.fm;
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.fm.IDemodulator;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
|
||||
/**
|
||||
* Interface for FM demodulator that provides squelch control.
|
||||
* Interface for AM demodulator that provides squelch control.
|
||||
*/
|
||||
public interface ISquelchingDemodulator extends IDemodulator
|
||||
{
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
|
||||
@@ -32,32 +31,63 @@ import io.github.dsheirer.source.SourceEvent;
|
||||
public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
|
||||
{
|
||||
private static final float ZERO = 0.0f;
|
||||
private static final float ENVELOPE_GAIN = 1.0f;
|
||||
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
|
||||
private static final double FADE_SECONDS = 0.005;
|
||||
|
||||
/**
|
||||
* Audio delay, longer than the worst case 25 ms for the squelch to detect loss of signal plus the fade duration, so
|
||||
* that the audio fades out before the carrier drop reaches the audio normalizer, which would otherwise produce a
|
||||
* thump at the end of a transmission.
|
||||
*/
|
||||
private static final double AUDIO_DELAY_SECONDS = 0.032;
|
||||
private final IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
private final IAmDemodulator mAmDemodulator;
|
||||
private final AdaptiveSquelch mAdaptiveSquelch;
|
||||
private final AmAudioNormalizer mAudioNormalizer = new AmAudioNormalizer(DEFAULT_SAMPLE_RATE);
|
||||
private float[] mDelayedEnvelope;
|
||||
private boolean[] mDelayedAudioEnabled;
|
||||
private int mDelayPointer = 0;
|
||||
private float mFadeGain = 0.0f;
|
||||
private float mFadeStep = (float)(1.0 / (DEFAULT_SAMPLE_RATE * FADE_SECONDS));
|
||||
private boolean mSquelchChanged = false;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param gain to apply to the demodulated AM samples (e.g. 500.0f)
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* @param squelchAutoTrack to enable the squelch noise floor auto tracking feature.
|
||||
* @param bandwidth of the channel in hertz
|
||||
*/
|
||||
public SquelchingAMDemodulator(float gain, float alpha, float squelchThreshold, boolean squelchAutoTrack)
|
||||
public SquelchingAMDemodulator(float squelchThreshold, boolean squelchAutoTrack, double bandwidth)
|
||||
{
|
||||
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(gain);
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
|
||||
mAmDemodulator = AmDemodulatorFactory.getAmDemodulator(ENVELOPE_GAIN);
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(squelchThreshold, squelchAutoTrack, bandwidth);
|
||||
setAudioDelay(DEFAULT_SAMPLE_RATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate.
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate and for the audio
|
||||
* normalizer timing.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mAdaptiveSquelch.setSampleRate(sampleRate);
|
||||
mAudioNormalizer.setSampleRate(sampleRate);
|
||||
mFadeStep = (float)Math.min(1.0, 1.0 / (sampleRate * FADE_SECONDS));
|
||||
setAudioDelay(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sizes the audio delay buffers.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
private void setAudioDelay(double sampleRate)
|
||||
{
|
||||
int length = (int)Math.ceil(sampleRate * AUDIO_DELAY_SECONDS);
|
||||
mDelayedEnvelope = new float[length];
|
||||
mDelayedAudioEnabled = new boolean[length];
|
||||
mDelayPointer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +102,7 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
|
||||
* Demodulates the complex sample arrays.
|
||||
* @param i inphase sample array
|
||||
* @param q quadrature sample array
|
||||
* @return demodulated AM samples with gain applied.
|
||||
* @return demodulated audio samples, normalized to the carrier level, or zero valued samples while squelched.
|
||||
*/
|
||||
public float[] demodulate(float[] i, float[] q)
|
||||
{
|
||||
@@ -86,9 +116,26 @@ public class SquelchingAMDemodulator implements ISquelchingDemodulator, Listener
|
||||
{
|
||||
mAdaptiveSquelch.process(magnitude[x]);
|
||||
|
||||
if(!(mAdaptiveSquelch.isUnmuted()))
|
||||
boolean audioEnabled = mAdaptiveSquelch.isAudioEnabled();
|
||||
float envelope = mDelayedEnvelope[mDelayPointer];
|
||||
boolean delayedAudioEnabled = mDelayedAudioEnabled[mDelayPointer];
|
||||
mDelayedEnvelope[mDelayPointer] = demodulated[x];
|
||||
mDelayedAudioEnabled[mDelayPointer] = audioEnabled;
|
||||
mDelayPointer = (mDelayPointer + 1) % mDelayedEnvelope.length;
|
||||
|
||||
//Fade in once the delayed audio reaches the squelch open point and fade out as soon as the squelch detects
|
||||
//loss of signal, ahead of the delayed audio.
|
||||
mFadeGain = (audioEnabled && delayedAudioEnabled) ? Math.min(mFadeGain + mFadeStep, 1.0f) :
|
||||
Math.max(mFadeGain - mFadeStep, 0.0f);
|
||||
|
||||
if(mFadeGain > ZERO)
|
||||
{
|
||||
demodulated[x] = mAudioNormalizer.process(envelope) * mFadeGain;
|
||||
}
|
||||
else
|
||||
{
|
||||
demodulated[x] = ZERO;
|
||||
mAudioNormalizer.reset();
|
||||
}
|
||||
|
||||
if(mAdaptiveSquelch.isSquelchChanged())
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator128 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_128;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator256 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_256;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator512 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_512;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.vector.VectorUtilities;
|
||||
import jdk.incubator.vector.FloatVector;
|
||||
import jdk.incubator.vector.VectorSpecies;
|
||||
@@ -32,7 +30,6 @@ public class VectorAMDemodulator64 implements IAmDemodulator
|
||||
{
|
||||
private static final VectorSpecies<Float> VECTOR_SPECIES = FloatVector.SPECIES_64;
|
||||
private float mGain;
|
||||
private IMagnitudeCalculator mMagnitudeCalculator = MagnitudeFactory.getMagnitudeCalculator();
|
||||
|
||||
/**
|
||||
* Constructs this demodulator where the specified gain is applied to demodulated output samples.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.filter;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Designs low-pass filters for complex sample channels, with a check of the designed frequency response.
|
||||
*/
|
||||
public class ChannelFilterDesigner
|
||||
{
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ChannelFilterDesigner.class);
|
||||
private static final double MAXIMUM_STOP_BAND_DB = -40.0;
|
||||
private static final double MAXIMUM_PASS_BAND_DB = 1.0;
|
||||
private static final int FILTER_RESPONSE_CHECK_POINTS = 1024;
|
||||
private static final int FALLBACK_FILTER_SIZING_ATTENUATION_DB = 75;
|
||||
private static final int FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB = 150;
|
||||
private static final int FALLBACK_FILTER_ATTENUATION_STEP_DB = 15;
|
||||
|
||||
/**
|
||||
* Designs the low-pass channel filter for complex samples, where the channel bandwidth spans the two-sided
|
||||
* (positive and negative frequency) signal. The stop band ripple provides approximately 46 dB of attenuation.
|
||||
*
|
||||
* The remez designer can converge on a filter that meets the specification at the design grid points but has a large
|
||||
* response spike outside of them, so the response of the designed filter is checked before it's used.
|
||||
*
|
||||
* @param sampleRate of the samples to filter in hertz
|
||||
* @param bandwidth of the channel in hertz
|
||||
* @return filter coefficients
|
||||
*/
|
||||
public static float[] design(double sampleRate, double bandwidth)
|
||||
{
|
||||
int passBandStop = (int)(bandwidth * .4);
|
||||
int stopBandStart = (int)(bandwidth * .5);
|
||||
|
||||
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
|
||||
.sampleRate(sampleRate)
|
||||
.gridDensity(16)
|
||||
.oddLength(true)
|
||||
.passBandCutoff(passBandStop)
|
||||
.passBandAmplitude(1.0)
|
||||
.passBandRipple(0.01)
|
||||
.stopBandStart(stopBandStart)
|
||||
.stopBandAmplitude(0.0)
|
||||
.stopBandRipple(0.005)
|
||||
.build();
|
||||
|
||||
float[] coefficients = null;
|
||||
|
||||
try
|
||||
{
|
||||
coefficients = FilterFactory.getTaps(specification);
|
||||
}
|
||||
catch(FilterDesignException fde)
|
||||
{
|
||||
LOGGER.warn("Couldn't design remez channel filter for sample rate [" + sampleRate +
|
||||
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart + "]");
|
||||
}
|
||||
|
||||
if(coefficients == null || !isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
|
||||
{
|
||||
LOGGER.info("Unable to use remez filter designer for sample rate [" + sampleRate + "] pass band stop [" +
|
||||
passBandStop + "] and stop band start [" + stopBandStart +
|
||||
"] - will proceed using simple low pass filter design");
|
||||
//Center the cutoff so the bandwidth matches the remez design and size the filter to meet the response limits.
|
||||
for(int attenuation = FALLBACK_FILTER_SIZING_ATTENUATION_DB; attenuation <= FALLBACK_FILTER_MAXIMUM_ATTENUATION_DB;
|
||||
attenuation += FALLBACK_FILTER_ATTENUATION_STEP_DB)
|
||||
{
|
||||
int length = FilterFactory.getLowPass(sampleRate, passBandStop, stopBandStart, attenuation,
|
||||
WindowType.HAMMING, true).length;
|
||||
coefficients = FilterFactory.getLowPass(sampleRate, (passBandStop + stopBandStart) / 2, length, WindowType.HAMMING);
|
||||
|
||||
if(isValidFilterResponse(coefficients, sampleRate, passBandStop, stopBandStart))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coefficients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the frequency response of the low-pass filter, relative to the response at DC.
|
||||
* @param coefficients of the filter
|
||||
* @param sampleRate of the filter in hertz
|
||||
* @param passBandStop frequency in hertz
|
||||
* @param stopBandStart frequency in hertz
|
||||
* @return true if the pass band and stop band responses are within limits, through to half the sample rate.
|
||||
*/
|
||||
public static boolean isValidFilterResponse(float[] coefficients, double sampleRate, int passBandStop, int stopBandStart)
|
||||
{
|
||||
double dc = getResponseDb(coefficients, 0.0, sampleRate);
|
||||
double nyquist = sampleRate / 2.0;
|
||||
|
||||
for(int x = 0; x <= FILTER_RESPONSE_CHECK_POINTS; x++)
|
||||
{
|
||||
double frequency = nyquist * x / FILTER_RESPONSE_CHECK_POINTS;
|
||||
double response = getResponseDb(coefficients, frequency, sampleRate) - dc;
|
||||
|
||||
if((frequency <= passBandStop && response > MAXIMUM_PASS_BAND_DB) ||
|
||||
(frequency >= stopBandStart && response > MAXIMUM_STOP_BAND_DB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the magnitude response of the filter at the specified frequency.
|
||||
*/
|
||||
public static double getResponseDb(float[] coefficients, double frequency, double sampleRate)
|
||||
{
|
||||
double radians = 2.0 * Math.PI * frequency / sampleRate;
|
||||
double real = 0.0;
|
||||
double imaginary = 0.0;
|
||||
|
||||
for(int x = 0; x < coefficients.length; x++)
|
||||
{
|
||||
real += coefficients[x] * Math.cos(radians * x);
|
||||
imaginary += coefficients[x] * Math.sin(radians * x);
|
||||
}
|
||||
|
||||
return 20.0 * Math.log10(Math.hypot(real, imaginary));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ public class RemezFIRFilterDesigner
|
||||
}
|
||||
catch( FilterDesignException fde )
|
||||
{
|
||||
mLog.error( "Filter design error - couldn't find extremal indices at count [" +
|
||||
mLog.warn( "Filter design error - couldn't find extremal indices at count [" +
|
||||
iterationCount + "] try changing filter order up/down from [" +
|
||||
mSpecification.getOrder() + "]" );
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2023 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
|
||||
package io.github.dsheirer.dsp.fm;
|
||||
|
||||
import io.github.dsheirer.dsp.magnitude.IMagnitudeCalculator;
|
||||
import io.github.dsheirer.dsp.magnitude.MagnitudeFactory;
|
||||
import io.github.dsheirer.dsp.squelch.AdaptiveSquelch;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* FM Demodulator for demodulating complex samples and producing demodulated floating point samples.
|
||||
*
|
||||
* Implements listener of source events to process runtime squelch threshold change request events
|
||||
* which are forwarded to the power squelch control.
|
||||
*/
|
||||
public class SquelchingFMDemodulator implements ISquelchingDemodulator, Listener<SourceEvent>
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(SquelchingFMDemodulator.class);
|
||||
private static final float ZERO = 0.0f;
|
||||
private final AdaptiveSquelch mAdaptiveSquelch;
|
||||
private final IMagnitudeCalculator mMagnitude = MagnitudeFactory.getMagnitudeCalculator();
|
||||
private final IDemodulator mFmDemodulator = FmDemodulatorFactory.getFmDemodulator();
|
||||
private boolean mSquelchChanged = false;
|
||||
|
||||
/**
|
||||
* Creates an FM demodulator instance with a default gain of 1.0.
|
||||
*
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* causes immediate mute and unmute. Set to higher count to prevent mute/unmute flapping.
|
||||
* @param squelchAutoTrack to enable the squelch noise floor auto-tracking feature.
|
||||
*/
|
||||
public SquelchingFMDemodulator(float alpha, float squelchThreshold, boolean squelchAutoTrack)
|
||||
{
|
||||
mAdaptiveSquelch = new AdaptiveSquelch(alpha, squelchThreshold, squelchAutoTrack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the sample rate for the squelch to adjust the power level notification rate.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mAdaptiveSquelch.setSampleRate(sampleRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the listener to receive notifications of squelch change events from the power squelch.
|
||||
*/
|
||||
public void setSourceEventListener(Listener<SourceEvent> listener)
|
||||
{
|
||||
mAdaptiveSquelch.setSourceEventListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demodulates the complex (I/Q) sample arrays
|
||||
* @param i inphase samples
|
||||
* @param q quadrature samples
|
||||
* @return demodulated real samples
|
||||
*/
|
||||
@Override
|
||||
public float[] demodulate(float[] i, float[] q)
|
||||
{
|
||||
setSquelchChanged(false);
|
||||
|
||||
float[] demodulated = mFmDemodulator.demodulate(i, q);
|
||||
float[] magnitude = mMagnitude.calculate(i, q);
|
||||
|
||||
for(int x = 0; x < magnitude.length; x++)
|
||||
{
|
||||
mAdaptiveSquelch.process(magnitude[x]);
|
||||
|
||||
if(!(mAdaptiveSquelch.isUnmuted()))
|
||||
{
|
||||
demodulated[x] = ZERO;
|
||||
}
|
||||
|
||||
if(mAdaptiveSquelch.isSquelchChanged())
|
||||
{
|
||||
setSquelchChanged(true);
|
||||
}
|
||||
}
|
||||
|
||||
return demodulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the threshold for squelch control
|
||||
* @param threshold (dB)
|
||||
*/
|
||||
public void setSquelchThreshold(float threshold)
|
||||
{
|
||||
mAdaptiveSquelch.setSquelchThreshold(threshold);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSquelchAutoTrack(boolean autoTrack)
|
||||
{
|
||||
mAdaptiveSquelch.setSquelchAutoTrack(autoTrack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the squelch state has changed during the processing of buffer(s)
|
||||
*/
|
||||
public boolean isSquelchChanged()
|
||||
{
|
||||
return mSquelchChanged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or resets the squelch changed flag.
|
||||
*/
|
||||
private void setSquelchChanged(boolean changed)
|
||||
{
|
||||
mSquelchChanged = changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the squelch state is currently muted
|
||||
*/
|
||||
public boolean isMuted()
|
||||
{
|
||||
return mAdaptiveSquelch.isMuted();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receive(SourceEvent sourceEvent)
|
||||
{
|
||||
switch(sourceEvent.getEvent())
|
||||
{
|
||||
//Only forward squelch threshold and auto-track current & change requests
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
case REQUEST_CURRENT_SQUELCH_AUTO_TRACK:
|
||||
case REQUEST_CHANGE_SQUELCH_AUTO_TRACK:
|
||||
mAdaptiveSquelch.receive(sourceEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,66 +19,106 @@
|
||||
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.iir.SinglePoleIirFilter;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import org.apache.commons.math3.util.FastMath;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Adaptive squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
* Adaptive power squelch control that optionally tracks the noise floor and auto-adjusts squelch threshold.
|
||||
*
|
||||
* Signal power is measured in 5 millisecond blocks so that all timing is independent of the sample rate. The squelch
|
||||
* opens after consecutive blocks at or above the threshold. It closes on a 20 millisecond power average that spans at
|
||||
* least one cycle of the lowest voice frequencies, so that modulation doesn't look like a loss of signal, and which
|
||||
* responds in a fixed time regardless of how strong the signal was.
|
||||
*
|
||||
* Closing occurs in two steps. Audio is disabled as soon as the power average falls below the close threshold, which
|
||||
* limits the noise tail, while the squelch stays open for a hang period to bridge a brief signal fade without ending
|
||||
* the call.
|
||||
*
|
||||
* When auto-track is enabled, the noise floor is the lowest power from the recent intervals that look like noise, using
|
||||
* the ratio mean(power^2) / mean(power)^2, which is 2.0 for gaussian noise and approaches 1.0 for a steady carrier. The
|
||||
* threshold is held at a margin over the noise floor, so a threshold below the noise floor can't latch the squelch open.
|
||||
* The squelch stays closed until the first noise floor measurement is complete. A signal that doesn't look like noise
|
||||
* is excluded from the noise floor unless it persists for the carrier timeout, after which it's treated as the noise
|
||||
* floor so that a dead carrier doesn't hold the squelch open. The noise floor recovers when the carrier ends. A carrier
|
||||
* with voice modulation, such as a continuous broadcast, doesn't time out, and a timed out carrier that develops voice
|
||||
* modulation is removed from the noise floor.
|
||||
*
|
||||
* Initially modeled on: https://github.com/gnuradio/gnuradio/blob/master/gr-analog/lib/simple_squelch_cc_impl.cc
|
||||
*/
|
||||
public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
private static final Logger mLog = LoggerFactory.getLogger(AdaptiveSquelch.class);
|
||||
private static final long AUTO_ADJUST_PERIOD_MS = Duration.ofSeconds(5).toMillis();
|
||||
private SinglePoleIirFilter mFilter;
|
||||
private static final double DEFAULT_SAMPLE_RATE = 50000.0;
|
||||
private static final double BLOCK_SECONDS = 0.005;
|
||||
private static final int OPEN_BLOCKS = 2; //10 milliseconds
|
||||
private static final int CLOSE_AVERAGE_BLOCKS = 4; //20 milliseconds
|
||||
private static final int HANG_BLOCKS = 20; //100 milliseconds
|
||||
private static final int POWER_INTERVAL_BLOCKS = 100; //500 milliseconds
|
||||
private static final int NOISE_WINDOW_INTERVALS = 20; //10 seconds
|
||||
private static final int CARRIER_TIMEOUT_INTERVALS = 120; //60 seconds
|
||||
private static final int MINIMUM_VOICE_INTERVALS = 3;
|
||||
private static final double NOISE_POWER_RATIO = 1.85;
|
||||
private static final float MINIMUM_SQUELCH_MARGIN = 4.0f; //6 dB
|
||||
private static final float CLOSE_THRESHOLD_RATIO = 0.5f; //3 dB below the open threshold
|
||||
private final float[] mCloseBlocks = new float[CLOSE_AVERAGE_BLOCKS];
|
||||
private int mCloseBlockPointer = 0;
|
||||
private float mCloseAccumulator = 0.0f;
|
||||
private int mBlockSize = (int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS);
|
||||
private int mBlockSampleCount = 0;
|
||||
private float mBlockAccumulator = 0.0f;
|
||||
private int mOpenCount = 0;
|
||||
private int mHangCount = 0;
|
||||
private float mPower = 0.0f;
|
||||
private boolean mSquelch;
|
||||
private float mSquelchThreshold;
|
||||
private double mIntervalPower = 0.0;
|
||||
private double mIntervalPowerSquared = 0.0;
|
||||
private int mIntervalSampleCount = 0;
|
||||
private int mIntervalBlockCount = 0;
|
||||
private boolean mFirstIntervalComplete = false;
|
||||
private final float[] mNoiseHistory = new float[NOISE_WINDOW_INTERVALS];
|
||||
private int mNoiseHistoryPointer = 0;
|
||||
private int mNoiseHistoryCount = 0;
|
||||
private int mCarrierIntervalCount = 0;
|
||||
private int mVoiceIntervalCount = 0;
|
||||
private boolean mCarrierTimedOut = false;
|
||||
private boolean mCarrierDetected = false;
|
||||
private Float mNoiseFloorBeforeCarrier;
|
||||
private final VoiceModulationDetector mVoiceModulationDetector;
|
||||
private Float mNoiseFloor;
|
||||
private float mSquelchBuffer;
|
||||
private Float mLowestNoiseFloor;
|
||||
private float mUserThreshold;
|
||||
private boolean mSquelch = true;
|
||||
private float mSquelchThreshold;
|
||||
private boolean mSquelchChanged = false;
|
||||
private int mPowerLevelBroadcastCount = 0;
|
||||
private int mPowerLevelBroadcastThreshold;
|
||||
private boolean mAutoTrackNoiseFloor;
|
||||
private long mLastAutoAdjustTimestamp;
|
||||
private Listener<SourceEvent> mSourceEventListener;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
*
|
||||
* Testing against a 12.5 kHz analog FM modulated signal, the following parameters provided a
|
||||
* good responsiveness. A threshold of -80.0 dB seemed to trigger significant flapping during un-squelching.
|
||||
* - alpha: 0.0001
|
||||
* - threshold: 78.0 dB
|
||||
*
|
||||
* @param alpha decay value of the single pole IIR filter in range: 0.0 - 1.0. The smaller the alpha value,
|
||||
* the slower the squelch response.
|
||||
* @param squelchThreshold in decibels. Signal power must exceed this threshold value for unsquelch.
|
||||
* @param autoTrack the noise floor when squelched and periodically readjust the squelch threshold.
|
||||
* @param bandwidth of the channel in hertz
|
||||
*/
|
||||
public AdaptiveSquelch(float alpha, float squelchThreshold, boolean autoTrack)
|
||||
public AdaptiveSquelch(float squelchThreshold, boolean autoTrack, double bandwidth)
|
||||
{
|
||||
mFilter = new SinglePoleIirFilter(alpha);
|
||||
mVoiceModulationDetector = new VoiceModulationDetector(DEFAULT_SAMPLE_RATE, bandwidth,
|
||||
(int)Math.round(DEFAULT_SAMPLE_RATE * BLOCK_SECONDS) * POWER_INTERVAL_BLOCKS);
|
||||
setSquelchThreshold(squelchThreshold);
|
||||
mAutoTrackNoiseFloor = autoTrack;
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
mPowerLevelBroadcastThreshold = 25000; //Based on a default sample rate of 50 kHz, so 2x/second
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the sample rate to effect the frequency of power level notifications where the notifications are
|
||||
* sent twice a second.
|
||||
* Sets the sample rate to size the power measurement blocks.
|
||||
* @param sampleRate in hertz
|
||||
*/
|
||||
public void setSampleRate(int sampleRate)
|
||||
{
|
||||
mPowerLevelBroadcastThreshold = sampleRate / 2;
|
||||
mBlockSize = Math.max(1, (int)Math.round(sampleRate * BLOCK_SECONDS));
|
||||
mVoiceModulationDetector.configure(sampleRate, mBlockSize * POWER_INTERVAL_BLOCKS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,32 +172,36 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold
|
||||
* Sets the squelch threshold. With auto-track enabled, the threshold is treated as a margin over the current noise
|
||||
* floor, subject to the minimum squelch margin. The margin is stored relative to the lowest observed noise floor so
|
||||
* that a change made while a timed out carrier has raised the noise floor applies once that carrier ends.
|
||||
* @param squelchThreshold in decibels
|
||||
*/
|
||||
public void setSquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
float squelchThresholdDb = fromDb(squelchThreshold);
|
||||
setSquelchThreshold(squelchThresholdDb, true);
|
||||
float threshold = fromDb(squelchThreshold);
|
||||
|
||||
if(mAutoTrackNoiseFloor && mNoiseFloor != null)
|
||||
{
|
||||
mUserThreshold = mLowestNoiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, threshold / mNoiseFloor);
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
else
|
||||
{
|
||||
mUserThreshold = threshold;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* Applies the squelch threshold. This method is synchronized to coordinate changes requested by the user/UI thread
|
||||
* and auto-track changes made by the stream processing thread.
|
||||
*
|
||||
* @param squelchThreshold value
|
||||
* @param resetNoiseFloor to reset the noise floor and squelch threshold
|
||||
*/
|
||||
private synchronized void setSquelchThreshold(float squelchThreshold, boolean resetNoiseFloor)
|
||||
private synchronized void applySquelchThreshold(float squelchThreshold)
|
||||
{
|
||||
mSquelchThreshold = squelchThreshold;
|
||||
|
||||
if(resetNoiseFloor)
|
||||
{
|
||||
//Set noise floor to null so that both the noise floor and the squelch buffer get updated after first power reading
|
||||
mNoiseFloor = null;
|
||||
}
|
||||
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
}
|
||||
|
||||
@@ -183,59 +227,231 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a complex IQ sample and changes squelch state when the signal power is above or below the
|
||||
* threshold value.
|
||||
* Processes a complex IQ sample and updates the squelch state at the end of each power measurement block.
|
||||
* @param magnitude of a complex sample (inphase * inphase + quadrature * quadrature)
|
||||
*/
|
||||
public void process(float magnitude)
|
||||
{
|
||||
mPower = mFilter.filter(magnitude);
|
||||
mBlockAccumulator += magnitude;
|
||||
mBlockSampleCount++;
|
||||
mIntervalPower += magnitude;
|
||||
mIntervalPowerSquared += (double)magnitude * magnitude;
|
||||
mIntervalSampleCount++;
|
||||
|
||||
if(mSquelch && mPower >= mSquelchThreshold)
|
||||
//Voice detection is only needed for a carrier, so that channels with only noise don't incur this cost.
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
setSquelch(false);
|
||||
}
|
||||
else if(!mSquelch && mPower < mSquelchThreshold)
|
||||
{
|
||||
setSquelch(true);
|
||||
mVoiceModulationDetector.receive((float)Math.sqrt(magnitude));
|
||||
}
|
||||
|
||||
mPowerLevelBroadcastCount++;
|
||||
if(mPowerLevelBroadcastCount % mPowerLevelBroadcastThreshold == 0)
|
||||
if(mBlockSampleCount >= mBlockSize)
|
||||
{
|
||||
mPowerLevelBroadcastCount = 0;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
adjustSquelchThreshold();
|
||||
}
|
||||
float power = mBlockAccumulator / mBlockSampleCount;
|
||||
mBlockAccumulator = 0.0f;
|
||||
mBlockSampleCount = 0;
|
||||
processBlock(power);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With the user specified squelch threshold, establishes a squelch buffer (delta) from the actual noise floor and
|
||||
* then automatically updates the squelch threshold to be the current noise floor plus this threshold value.
|
||||
*
|
||||
* Note: this method should only be invoked from a squelch (true) state.
|
||||
* Updates the squelch state from a block power measurement.
|
||||
* @param power of the block
|
||||
*/
|
||||
private void adjustSquelchThreshold()
|
||||
private void processBlock(float power)
|
||||
{
|
||||
if(mAutoTrackNoiseFloor)
|
||||
mCloseAccumulator += power - mCloseBlocks[mCloseBlockPointer];
|
||||
mCloseBlocks[mCloseBlockPointer] = power;
|
||||
mCloseBlockPointer = (mCloseBlockPointer + 1) % CLOSE_AVERAGE_BLOCKS;
|
||||
|
||||
if(mSquelch)
|
||||
{
|
||||
if(mNoiseFloor == null)
|
||||
//With auto-track, the threshold isn't reliable until there's a noise floor measurement.
|
||||
boolean ready = mFirstIntervalComplete || !mAutoTrackNoiseFloor;
|
||||
mOpenCount = (ready && power >= mSquelchThreshold) ? mOpenCount + 1 : 0;
|
||||
|
||||
if(mOpenCount >= OPEN_BLOCKS)
|
||||
{
|
||||
//We set the squelch buffer just once, after the squelch threshold is set and noise floor is set to null
|
||||
mSquelchBuffer = mSquelchThreshold - mPower;
|
||||
mOpenCount = 0;
|
||||
mHangCount = 0;
|
||||
//Seed the close average so that it doesn't start with the blocks from before the signal arrived.
|
||||
Arrays.fill(mCloseBlocks, power);
|
||||
mCloseAccumulator = power * CLOSE_AVERAGE_BLOCKS;
|
||||
setSquelch(false);
|
||||
}
|
||||
}
|
||||
else if(mCloseAccumulator / CLOSE_AVERAGE_BLOCKS >= mSquelchThreshold * CLOSE_THRESHOLD_RATIO)
|
||||
{
|
||||
mHangCount = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
mHangCount++;
|
||||
|
||||
if(mHangCount >= HANG_BLOCKS)
|
||||
{
|
||||
setSquelch(true);
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts power blocks to provide periodic power level notifications and noise floor updates.
|
||||
*/
|
||||
private void updatePowerInterval()
|
||||
{
|
||||
mIntervalBlockCount++;
|
||||
|
||||
if(mIntervalBlockCount >= POWER_INTERVAL_BLOCKS)
|
||||
{
|
||||
double mean = mIntervalPower / mIntervalSampleCount;
|
||||
mPower = (float)mean;
|
||||
broadcast(SourceEvent.channelPowerLevel(null, 10.0 * Math.log10(mPower)));
|
||||
|
||||
if(mAutoTrackNoiseFloor && mean > 0.0)
|
||||
{
|
||||
updateNoiseFloor(mIntervalPowerSquared / mIntervalSampleCount / (mean * mean));
|
||||
}
|
||||
|
||||
mNoiseFloor = mPower;
|
||||
mFirstIntervalComplete = true;
|
||||
mIntervalPower = 0.0;
|
||||
mIntervalPowerSquared = 0.0;
|
||||
mIntervalSampleCount = 0;
|
||||
mIntervalBlockCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if((System.currentTimeMillis() - mLastAutoAdjustTimestamp) > AUTO_ADJUST_PERIOD_MS)
|
||||
{
|
||||
setSquelchThreshold(mNoiseFloor + mSquelchBuffer, false);
|
||||
mLastAutoAdjustTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
/**
|
||||
* Updates the noise floor history from the current interval power and updates the squelch threshold.
|
||||
* @param powerRatio mean(power^2) / mean(power)^2 for the interval
|
||||
*/
|
||||
private void updateNoiseFloor(double powerRatio)
|
||||
{
|
||||
boolean voice = mVoiceModulationDetector.analyze();
|
||||
mCarrierDetected = powerRatio < NOISE_POWER_RATIO;
|
||||
|
||||
if(mCarrierDetected)
|
||||
{
|
||||
updateCarrier(voice);
|
||||
}
|
||||
else
|
||||
{
|
||||
resetCarrierTracking();
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mNoiseHistoryCount > 0)
|
||||
{
|
||||
updateTrackedThreshold();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the carrier timeout from an interval that doesn't look like noise. A carrier that lasts for the timeout
|
||||
* period is treated as the noise floor, unless it has voice modulation.
|
||||
* @param voice true if voice modulation was detected in this interval
|
||||
*/
|
||||
private void updateCarrier(boolean voice)
|
||||
{
|
||||
mCarrierIntervalCount++;
|
||||
|
||||
if(voice)
|
||||
{
|
||||
mVoiceIntervalCount++;
|
||||
}
|
||||
|
||||
boolean hasVoice = mVoiceIntervalCount >= MINIMUM_VOICE_INTERVALS;
|
||||
|
||||
if(mCarrierTimedOut && hasVoice)
|
||||
{
|
||||
restoreNoiseFloor();
|
||||
resetCarrierTracking();
|
||||
}
|
||||
else if(mCarrierTimedOut)
|
||||
{
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
else if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS && !hasVoice)
|
||||
{
|
||||
//Replace the history so that the carrier takes effect immediately.
|
||||
mCarrierTimedOut = true;
|
||||
mNoiseFloorBeforeCarrier = mNoiseFloor;
|
||||
mNoiseHistoryCount = 0;
|
||||
addNoisePower(mPower);
|
||||
}
|
||||
|
||||
if(mCarrierIntervalCount >= CARRIER_TIMEOUT_INTERVALS)
|
||||
{
|
||||
resetCarrierTracking();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the counts for the current carrier evaluation period.
|
||||
*/
|
||||
private void resetCarrierTracking()
|
||||
{
|
||||
mCarrierIntervalCount = 0;
|
||||
mVoiceIntervalCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a timed out carrier from the noise floor, restoring the noise floor from before the carrier. If there's
|
||||
* no noise floor from before the carrier, the user threshold is restored until the noise floor can be measured.
|
||||
*/
|
||||
private void restoreNoiseFloor()
|
||||
{
|
||||
mCarrierTimedOut = false;
|
||||
mNoiseHistoryCount = 0;
|
||||
|
||||
if(mNoiseFloorBeforeCarrier != null)
|
||||
{
|
||||
addNoisePower(mNoiseFloorBeforeCarrier);
|
||||
mNoiseFloorBeforeCarrier = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
mNoiseFloor = null;
|
||||
mLowestNoiseFloor = null;
|
||||
applySquelchThreshold(mUserThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the power to the noise history.
|
||||
* @param power of the interval
|
||||
*/
|
||||
private void addNoisePower(float power)
|
||||
{
|
||||
mNoiseHistory[mNoiseHistoryPointer] = power;
|
||||
mNoiseHistoryPointer = (mNoiseHistoryPointer + 1) % NOISE_WINDOW_INTERVALS;
|
||||
mNoiseHistoryCount = Math.min(mNoiseHistoryCount + 1, NOISE_WINDOW_INTERVALS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the squelch threshold to the margin over the current noise floor, where the margin is the larger of the
|
||||
* minimum margin and the amount that the user threshold exceeds the lowest observed noise floor.
|
||||
*/
|
||||
private void updateTrackedThreshold()
|
||||
{
|
||||
float noiseFloor = Float.MAX_VALUE;
|
||||
|
||||
for(int x = 0; x < mNoiseHistoryCount; x++)
|
||||
{
|
||||
int index = (mNoiseHistoryPointer - 1 - x + NOISE_WINDOW_INTERVALS) % NOISE_WINDOW_INTERVALS;
|
||||
noiseFloor = Math.min(noiseFloor, mNoiseHistory[index]);
|
||||
}
|
||||
|
||||
mNoiseFloor = noiseFloor;
|
||||
mLowestNoiseFloor = (mLowestNoiseFloor == null) ? noiseFloor : Math.min(mLowestNoiseFloor, noiseFloor);
|
||||
float threshold = noiseFloor * Math.max(MINIMUM_SQUELCH_MARGIN, mUserThreshold / mLowestNoiseFloor);
|
||||
|
||||
if(threshold != mSquelchThreshold)
|
||||
{
|
||||
applySquelchThreshold(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +471,15 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
return !mSquelch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if audio should be passed. This is false during the hang period, while the signal is below the close
|
||||
* threshold but the squelch is still open.
|
||||
*/
|
||||
public boolean isAudioEnabled()
|
||||
{
|
||||
return !mSquelch && mHangCount == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current power level
|
||||
* @return current power level in dB
|
||||
@@ -309,6 +534,7 @@ public class AdaptiveSquelch implements Listener<SourceEvent>
|
||||
{
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
setSquelchThreshold(sourceEvent.getValue().floatValue());
|
||||
broadcast(SourceEvent.squelchUserThreshold(toDb(mUserThreshold)));
|
||||
break;
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
broadcast(SourceEvent.squelchThreshold(null, getSquelchThreshold()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import org.jtransforms.fft.FloatFFT_1D;
|
||||
|
||||
/**
|
||||
* Detects voice modulation on the envelope of a steady AM carrier. The noise on a carrier's envelope is spectrally flat
|
||||
* across the channel, while voice energy is concentrated in the lower part of the voice band. The detector compares
|
||||
* the average spectral density of a low and a high band, with band edges limited to the channel pass band.
|
||||
*
|
||||
* A single detection isn't proof of a voice transmission and a pause in speech isn't proof of a dead carrier, so the
|
||||
* results should be evaluated across a longer period.
|
||||
*/
|
||||
public class VoiceModulationDetector
|
||||
{
|
||||
private static final float MAXIMUM_FREQUENCY = 2300.0f;
|
||||
private static final float MAXIMUM_FREQUENCY_BANDWIDTH_RATIO = 0.38f;
|
||||
private static final float LOW_BAND_START = 300.0f;
|
||||
private static final float LOW_BAND_STOP_RATIO = 0.52f;
|
||||
private static final float HIGH_BAND_START_RATIO = 0.65f;
|
||||
private static final double DETECTION_THRESHOLD_DB = 5.0;
|
||||
|
||||
private final double mBandwidth;
|
||||
private FloatFFT_1D mFFT;
|
||||
private float[] mEnvelope;
|
||||
private float[] mWindow;
|
||||
private int mLowStartBin;
|
||||
private int mLowStopBin;
|
||||
private int mHighStartBin;
|
||||
private int mHighStopBin;
|
||||
private int mPointer = 0;
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
* @param bandwidth of the channel in hertz, spanning positive and negative frequencies
|
||||
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
|
||||
*/
|
||||
public VoiceModulationDetector(double sampleRate, double bandwidth, int analysisSamples)
|
||||
{
|
||||
mBandwidth = bandwidth;
|
||||
configure(sampleRate, analysisSamples);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sample rate and resets this detector.
|
||||
* @param sampleRate of the envelope samples in hertz
|
||||
* @param analysisSamples maximum number of samples per analysis. This is reduced to a power of 2.
|
||||
*/
|
||||
public final void configure(double sampleRate, int analysisSamples)
|
||||
{
|
||||
int length = Integer.highestOneBit(analysisSamples);
|
||||
mFFT = new FloatFFT_1D(length);
|
||||
mEnvelope = new float[length];
|
||||
mWindow = new float[length];
|
||||
|
||||
for(int x = 0; x < length; x++)
|
||||
{
|
||||
mWindow[x] = (float)(0.5 - 0.5 * Math.cos(2.0 * Math.PI * x / length));
|
||||
}
|
||||
|
||||
float stop = Math.min(MAXIMUM_FREQUENCY, (float)mBandwidth * MAXIMUM_FREQUENCY_BANDWIDTH_RATIO);
|
||||
double hertzPerBin = sampleRate / length;
|
||||
mLowStartBin = (int)Math.ceil(LOW_BAND_START / hertzPerBin);
|
||||
mLowStopBin = (int)(stop * LOW_BAND_STOP_RATIO / hertzPerBin);
|
||||
mHighStartBin = (int)Math.ceil(stop * HIGH_BAND_START_RATIO / hertzPerBin);
|
||||
mHighStopBin = (int)(stop / hertzPerBin);
|
||||
reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards any partial analysis buffer.
|
||||
*/
|
||||
public void reset()
|
||||
{
|
||||
mPointer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an envelope sample to the analysis buffer. Samples are ignored once the buffer is full.
|
||||
* @param envelope sample
|
||||
*/
|
||||
public void receive(float envelope)
|
||||
{
|
||||
if(mPointer < mEnvelope.length)
|
||||
{
|
||||
mEnvelope[mPointer++] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes the buffered samples and resets this detector.
|
||||
* @return true if the analysis buffer is full and the samples contain voice modulation.
|
||||
*/
|
||||
public boolean analyze()
|
||||
{
|
||||
boolean full = mPointer == mEnvelope.length;
|
||||
mPointer = 0;
|
||||
|
||||
if(!full)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float mean = 0.0f;
|
||||
|
||||
for(float sample: mEnvelope)
|
||||
{
|
||||
mean += sample;
|
||||
}
|
||||
|
||||
mean /= mEnvelope.length;
|
||||
|
||||
for(int x = 0; x < mEnvelope.length; x++)
|
||||
{
|
||||
mEnvelope[x] = (mEnvelope[x] - mean) * mWindow[x];
|
||||
}
|
||||
|
||||
mFFT.realForward(mEnvelope);
|
||||
double low = getBandPower(mLowStartBin, mLowStopBin);
|
||||
double high = getBandPower(mHighStartBin, mHighStopBin);
|
||||
return high > 0.0 && 10.0 * Math.log10(low / high) >= DETECTION_THRESHOLD_DB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the mean power of the bins from the real transform output of interleaved real and imaginary values.
|
||||
*/
|
||||
private double getBandPower(int startBin, int stopBin)
|
||||
{
|
||||
double power = 0.0;
|
||||
|
||||
for(int bin = startBin; bin < stopBin; bin++)
|
||||
{
|
||||
double real = mEnvelope[2 * bin];
|
||||
double imaginary = mEnvelope[2 * bin + 1];
|
||||
power += real * real + imaginary * imaginary;
|
||||
}
|
||||
|
||||
return power / Math.max(1, stopBin - startBin);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
|
||||
DecodeConfigAM.Bandwidth bandwidth = config.getBandwidth();
|
||||
if(bandwidth == null)
|
||||
{
|
||||
bandwidth = DecodeConfigNBFM.Bandwidth.BW_15_0;
|
||||
bandwidth = DecodeConfigAM.DEFAULT_BANDWIDTH;
|
||||
}
|
||||
|
||||
for(Toggle toggle: getBandwidthButton().getToggleGroup().getToggles())
|
||||
@@ -409,7 +409,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
|
||||
{
|
||||
getBandwidthButton().setDisable(false);
|
||||
final DecodeConfigAM.Bandwidth bandwidth = (decodeConfigAM.getBandwidth() != null ?
|
||||
decodeConfigAM.getBandwidth() : DecodeConfigNBFM.Bandwidth.BW_15_0);
|
||||
decodeConfigAM.getBandwidth() : DecodeConfigAM.DEFAULT_BANDWIDTH);
|
||||
|
||||
for(Toggle toggle: getBandwidthButton().getToggleGroup().getToggles())
|
||||
{
|
||||
@@ -454,7 +454,7 @@ public class AMConfigurationEditor extends ChannelConfigurationEditor
|
||||
config = new DecodeConfigAM();
|
||||
}
|
||||
|
||||
DecodeConfigAM.Bandwidth bandwidth = DecodeConfigAM.Bandwidth.BW_15_0;
|
||||
DecodeConfigAM.Bandwidth bandwidth = DecodeConfigAM.DEFAULT_BANDWIDTH;
|
||||
|
||||
if(getBandwidthButton().getToggleGroup().getSelectedToggle() != null)
|
||||
{
|
||||
|
||||
@@ -109,6 +109,7 @@ public class SignalPowerView extends JPanel
|
||||
mSquelchAutoTrackCheckBox.addActionListener(e ->
|
||||
{
|
||||
broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected()));
|
||||
setConfigSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected());
|
||||
});
|
||||
valuePanel.add(mSquelchAutoTrackCheckBox, "span,left");
|
||||
|
||||
@@ -116,7 +117,11 @@ public class SignalPowerView extends JPanel
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the channel's decode configuration with a new squelch threshold value
|
||||
* Updates the channel's decode configuration with a new squelch threshold value.
|
||||
*
|
||||
* Note: this is only used for the user threshold notification that follows a user change. The threshold
|
||||
* notifications from the decoder can be values that are automatically tracked from current channel conditions, and
|
||||
* saving those would replace the user's setting.
|
||||
*/
|
||||
private void setConfigSquelchThreshold(int threshold)
|
||||
{
|
||||
@@ -195,7 +200,6 @@ public class SignalPowerView extends JPanel
|
||||
{
|
||||
final double threshold = sourceEvent.getValue().doubleValue();
|
||||
mSquelchThreshold = threshold;
|
||||
setConfigSquelchThreshold((int)threshold);
|
||||
|
||||
EventQueue.invokeLater(() -> {
|
||||
mPowerMeter.setSquelchThreshold(threshold);
|
||||
@@ -206,10 +210,11 @@ public class SignalPowerView extends JPanel
|
||||
mSquelchUpButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
case NOTIFICATION_SQUELCH_USER_THRESHOLD ->
|
||||
setConfigSquelchThreshold((int)Math.round(sourceEvent.getValue().doubleValue()));
|
||||
case NOTIFICATION_SQUELCH_AUTO_TRACK ->
|
||||
{
|
||||
boolean autoTrack = sourceEvent.getValue().intValue() == 1;
|
||||
setConfigSquelchAutoTrack(autoTrack);
|
||||
EventQueue.invokeLater(() -> {
|
||||
mSquelchAutoTrackCheckBox.setSelected(autoTrack);
|
||||
mSquelchAutoTrackCheckBox.setEnabled(true);
|
||||
@@ -225,5 +230,9 @@ public class SignalPowerView extends JPanel
|
||||
{
|
||||
mProcessingChain = processingChain;
|
||||
reset();
|
||||
|
||||
//Request the current squelch settings, which enables the squelch controls when the decoder responds.
|
||||
broadcast(SourceEvent.requestCurrentSquelchThreshold());
|
||||
broadcast(SourceEvent.requestCurrentSquelchAutoTrack());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -21,20 +21,14 @@ package io.github.dsheirer.module.decode.am;
|
||||
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||
import io.github.dsheirer.channel.state.IDecoderStateEventProvider;
|
||||
import io.github.dsheirer.channel.state.State;
|
||||
import io.github.dsheirer.dsp.am.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.am.SquelchingAMDemodulator;
|
||||
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
|
||||
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
|
||||
import io.github.dsheirer.dsp.filter.resample.RealResampler;
|
||||
import io.github.dsheirer.dsp.fm.ISquelchingDemodulator;
|
||||
import io.github.dsheirer.dsp.gain.AudioGainAndDcFilter;
|
||||
import io.github.dsheirer.dsp.squelch.INoiseSquelchController;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelch;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.module.decode.DecoderType;
|
||||
import io.github.dsheirer.module.decode.SquelchControlDecoder;
|
||||
import io.github.dsheirer.sample.Listener;
|
||||
@@ -52,15 +46,9 @@ import org.slf4j.LoggerFactory;
|
||||
*/
|
||||
public class AMDecoder extends SquelchControlDecoder implements ISourceEventListener, ISourceEventProvider,
|
||||
IComplexSamplesListener, Listener<ComplexSamples>, IRealBufferProvider,
|
||||
IDecoderStateEventProvider, INoiseSquelchController
|
||||
IDecoderStateEventProvider
|
||||
{
|
||||
private final static Logger mLog = LoggerFactory.getLogger(AMDecoder.class);
|
||||
private static final float DEMODULATOR_GAIN = 150.0f;
|
||||
private static final float SQUELCH_ALPHA_DECAY = 0.0004f;
|
||||
private static final float MINIMUM_GAIN = 0.5f;
|
||||
private static final float MAXIMUM_GAIN = 16.0f;
|
||||
private static final float OBJECTIVE_AUDIO_AMPLITUDE = 0.75f;
|
||||
private AudioGainAndDcFilter mAGC = new AudioGainAndDcFilter(MINIMUM_GAIN, MAXIMUM_GAIN, OBJECTIVE_AUDIO_AMPLITUDE);
|
||||
private static final double DEMODULATED_AUDIO_SAMPLE_RATE = 8000.0;
|
||||
private IRealFilter mIBasebandFilter;
|
||||
private IRealFilter mQBasebandFilter;
|
||||
@@ -73,7 +61,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
private Listener<DecoderStateEvent> mDecoderStateEventListener;
|
||||
private final double mChannelBandwidth;
|
||||
protected boolean mSquelch = true;
|
||||
private final NoiseSquelch mNoiseSquelch = new NoiseSquelch(0.1f, 0.2f, 4, 6);
|
||||
|
||||
/**
|
||||
* Constructs an instance
|
||||
@@ -83,8 +70,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
public AMDecoder(DecodeConfigAM config)
|
||||
{
|
||||
super(config);
|
||||
mDemodulator = new SquelchingAMDemodulator(DEMODULATOR_GAIN, SQUELCH_ALPHA_DECAY, config.getSquelchThreshold(),
|
||||
config.isSquelchAutoTrack());
|
||||
mDemodulator = new SquelchingAMDemodulator(config.getSquelchThreshold(), config.isSquelchAutoTrack(),
|
||||
config.getBandwidth().getValue());
|
||||
mChannelBandwidth = config.getBandwidth().getValue();
|
||||
}
|
||||
|
||||
@@ -94,30 +81,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
return DecoderType.AM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSquelchOverride(boolean override)
|
||||
{
|
||||
mNoiseSquelch.setSquelchOverride(override);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setHysteresisThreshold(int open, int close)
|
||||
{
|
||||
mNoiseSquelch.setHysteresisThreshold(open, close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setNoiseThreshold(float open, float close)
|
||||
{
|
||||
mNoiseSquelch.setNoiseThreshold(open, close);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setNoiseSquelchStateListener(Listener<NoiseSquelchState> listener)
|
||||
{
|
||||
mNoiseSquelch.setNoiseSquelchStateListener(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the ISourceEventListener interface
|
||||
*/
|
||||
@@ -154,8 +117,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
if(mResampledBufferListener != null)
|
||||
{
|
||||
mResampledBufferListener.receive(demodulatedSamples);
|
||||
// //Apply audio gain and rebroadcast
|
||||
// super.broadcast(mAGC.process(demodulatedSamples));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +157,7 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
{
|
||||
if(mIDecimationFilter == null || mQDecimationFilter == null)
|
||||
{
|
||||
throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source " +
|
||||
throw new IllegalStateException("AM demodulator module must receive a sample rate change source " +
|
||||
"event before it can process complex sample buffers");
|
||||
}
|
||||
|
||||
@@ -208,8 +169,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
|
||||
float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ);
|
||||
|
||||
mNoiseSquelch.process(demodulated);
|
||||
|
||||
if(mResampler != null)
|
||||
{
|
||||
//Squelch changed while processing this audio buffer
|
||||
@@ -225,6 +184,8 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
//Demodulator says to mute and we are unmuted = Squelch and End Call
|
||||
else
|
||||
{
|
||||
//Process the final audio and flush the resampler so that audio doesn't carry into the next call
|
||||
mResampler.resample(demodulated, true);
|
||||
mSquelch = true;
|
||||
notifyCallEnd();
|
||||
}
|
||||
@@ -275,7 +236,6 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
*/
|
||||
protected void notifyCallStart()
|
||||
{
|
||||
mAGC.reset();
|
||||
broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0));
|
||||
}
|
||||
|
||||
@@ -398,53 +358,17 @@ public class AMDecoder extends SquelchControlDecoder implements ISourceEventList
|
||||
mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
|
||||
mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate);
|
||||
|
||||
if((decimatedSampleRate < (2.0 * mChannelBandwidth)))
|
||||
//The complex sample rate must span the two-sided channel bandwidth.
|
||||
if(decimatedSampleRate < mChannelBandwidth)
|
||||
{
|
||||
throw new IllegalStateException(getDecoderType().name() + " demodulator with channel bandwidth [" +
|
||||
mChannelBandwidth + "] requires a channel sample rate of [" + (2.0 * mChannelBandwidth +
|
||||
"] - sample rate of [" + decimatedSampleRate + "] is not supported"));
|
||||
mChannelBandwidth + "] requires a channel sample rate of at least [" + mChannelBandwidth +
|
||||
"] - sample rate of [" + decimatedSampleRate + "] is not supported");
|
||||
}
|
||||
|
||||
mDemodulator.setSampleRate((int) decimatedSampleRate);
|
||||
|
||||
mNoiseSquelch.setSampleRate(decimatedSampleRate);
|
||||
|
||||
int passBandStop = (int) (mChannelBandwidth * .8);
|
||||
int stopBandStart = (int) mChannelBandwidth;
|
||||
|
||||
float[] coefficients = null;
|
||||
|
||||
FIRFilterSpecification specification = FIRFilterSpecification.lowPassBuilder()
|
||||
.sampleRate(decimatedSampleRate * 2)
|
||||
.gridDensity(16)
|
||||
.oddLength(true)
|
||||
.passBandCutoff(passBandStop)
|
||||
.passBandAmplitude(1.0)
|
||||
.passBandRipple(0.01)
|
||||
.stopBandStart(stopBandStart)
|
||||
.stopBandAmplitude(0.0)
|
||||
.stopBandRipple(0.005) //Approximately 90 dB attenuation
|
||||
.build();
|
||||
|
||||
try
|
||||
{
|
||||
coefficients = FilterFactory.getTaps(specification);
|
||||
}
|
||||
catch(FilterDesignException fde)
|
||||
{
|
||||
mLog.error("Couldn't design demodulator remez filter for sample rate [" + sampleRate +
|
||||
"] pass frequency [" + passBandStop + "] and stop frequency [" + stopBandStart +
|
||||
"] - will proceed using sinc (low-pass) filter");
|
||||
}
|
||||
|
||||
if(coefficients == null)
|
||||
{
|
||||
mLog.info("Unable to use remez filter designer for sample rate [" + decimatedSampleRate +
|
||||
"] pass band stop [" + passBandStop +
|
||||
"] and stop band start [" + stopBandStart + "] - will proceed using simple low pass filter design");
|
||||
coefficients = FilterFactory.getLowPass(decimatedSampleRate, passBandStop, stopBandStart, 60,
|
||||
WindowType.HAMMING, true);
|
||||
}
|
||||
float[] coefficients = ChannelFilterDesigner.design(decimatedSampleRate, mChannelBandwidth);
|
||||
|
||||
mIBasebandFilter = FilterFactory.getRealFilter(coefficients);
|
||||
mQBasebandFilter = FilterFactory.getRealFilter(coefficients);
|
||||
|
||||
@@ -30,6 +30,7 @@ import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
|
||||
*/
|
||||
public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfiguration
|
||||
{
|
||||
public static final Bandwidth DEFAULT_BANDWIDTH = Bandwidth.BW_6_0;
|
||||
private int mSquelchThreshold = -78;
|
||||
private boolean mSquelchAutoTrack = true;
|
||||
|
||||
@@ -46,7 +47,7 @@ public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfig
|
||||
@Override
|
||||
protected Bandwidth getDefaultBandwidth()
|
||||
{
|
||||
return Bandwidth.BW_15_0;
|
||||
return DEFAULT_BANDWIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,12 +63,14 @@ public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfig
|
||||
return new ChannelSpecification(25000.0, 3000, 1500.0, 1700.0);
|
||||
case BW_5_0:
|
||||
return new ChannelSpecification(25000.0, 5000, 2500.0, 2700.0);
|
||||
case BW_6_0:
|
||||
return new ChannelSpecification(25000.0, 6000, 3000.0, 3200.0);
|
||||
case BW_8_33:
|
||||
return new ChannelSpecification(25000.0, 10000, 5000.0, 7000.0);
|
||||
case BW_15_0:
|
||||
return new ChannelSpecification(25000.0, 15000, 7500.0, 9500.0);
|
||||
return new ChannelSpecification(30000.0, 15000, 7500.0, 9500.0);
|
||||
case BW_25_0:
|
||||
return new ChannelSpecification(25000.0, 25000, 12500.0, 14500.0);
|
||||
return new ChannelSpecification(50000.0, 25000, 12500.0, 14500.0);
|
||||
default:
|
||||
throw new IllegalArgumentException("Unrecognized AM bandwidth value: " + getBandwidth());
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
|
||||
{
|
||||
BW_3_0("3.0 kHz", 3000.0),
|
||||
BW_5_0("5.0 kHz", 5000.0),
|
||||
BW_6_0("6.0 kHz", 6000.0),
|
||||
BW_7_5("7.5 kHz", 7500.0),
|
||||
BW_8_33("8.33 kHz", 8333.0),
|
||||
BW_12_5("12.5 kHz", 12500.0),
|
||||
@@ -121,7 +122,7 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration
|
||||
}
|
||||
|
||||
//AM demodulator channel bandwidth options
|
||||
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_8_33, BW_15_0, BW_25_0);
|
||||
public static EnumSet<Bandwidth> AM_BANDWIDTHS = EnumSet.of(BW_3_0, BW_5_0, BW_6_0, BW_8_33, BW_15_0, BW_25_0);
|
||||
|
||||
//FM demodulator channel bandwidth options
|
||||
public static EnumSet<Bandwidth> FM_BANDWIDTHS = EnumSet.of(BW_7_5, BW_12_5, BW_25_0);
|
||||
|
||||
@@ -22,11 +22,10 @@ import io.github.dsheirer.audio.squelch.SquelchState;
|
||||
import io.github.dsheirer.channel.state.DecoderStateEvent;
|
||||
import io.github.dsheirer.channel.state.IDecoderStateEventProvider;
|
||||
import io.github.dsheirer.channel.state.State;
|
||||
import io.github.dsheirer.dsp.filter.ChannelFilterDesigner;
|
||||
import io.github.dsheirer.dsp.filter.FilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
|
||||
import io.github.dsheirer.dsp.filter.decimate.IRealDecimationFilter;
|
||||
import io.github.dsheirer.dsp.filter.design.FilterDesignException;
|
||||
import io.github.dsheirer.dsp.filter.fir.FIRFilterSpecification;
|
||||
import io.github.dsheirer.dsp.filter.fir.real.IRealFilter;
|
||||
import io.github.dsheirer.dsp.filter.resample.RealResampler;
|
||||
import io.github.dsheirer.dsp.fm.FmDemodulatorFactory;
|
||||
@@ -34,7 +33,6 @@ import io.github.dsheirer.dsp.fm.IDemodulator;
|
||||
import io.github.dsheirer.dsp.squelch.INoiseSquelchController;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelch;
|
||||
import io.github.dsheirer.dsp.squelch.NoiseSquelchState;
|
||||
import io.github.dsheirer.dsp.window.WindowType;
|
||||
import io.github.dsheirer.module.decode.DecoderType;
|
||||
import io.github.dsheirer.module.decode.SquelchControlDecoder;
|
||||
import io.github.dsheirer.module.decode.squelch.SquelchCodeState;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}].
|
||||
|
||||
@@ -45,6 +45,7 @@ public class SourceEvent
|
||||
NOTIFICATION_RECORDING_FILE_LOADED,
|
||||
NOTIFICATION_SAMPLE_RATE_CHANGE,
|
||||
NOTIFICATION_SQUELCH_THRESHOLD,
|
||||
NOTIFICATION_SQUELCH_USER_THRESHOLD,
|
||||
NOTIFICATION_SQUELCH_AUTO_TRACK,
|
||||
NOTIFICATION_STOP_SAMPLE_STREAM,
|
||||
NOTIFICATION_TUNER_SHUTDOWN,
|
||||
@@ -428,6 +429,17 @@ public class SourceEvent
|
||||
return new SourceEvent(Event.NOTIFICATION_SQUELCH_THRESHOLD, source, thresholdDb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new squelch user threshold notification, which is the setting to save after a user change request. This
|
||||
* can differ from the current squelch threshold when auto-tracking the noise floor.
|
||||
* @param thresholdDb setting
|
||||
* @return new source event
|
||||
*/
|
||||
public static SourceEvent squelchUserThreshold(double thresholdDb)
|
||||
{
|
||||
return new SourceEvent(Event.NOTIFICATION_SQUELCH_USER_THRESHOLD, null, thresholdDb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current squelch threshold value
|
||||
* @return source event
|
||||
|
||||
@@ -204,6 +204,7 @@ public abstract class TunerChannelSource extends ComplexSource implements ISourc
|
||||
case NOTIFICATION_FREQUENCY_ROTATION_SUCCESS:
|
||||
case NOTIFICATION_CHANNEL_POWER:
|
||||
case NOTIFICATION_SQUELCH_THRESHOLD:
|
||||
case NOTIFICATION_SQUELCH_USER_THRESHOLD:
|
||||
case NOTIFICATION_SQUELCH_AUTO_TRACK:
|
||||
case REQUEST_CHANGE_SQUELCH_THRESHOLD:
|
||||
case REQUEST_CURRENT_SQUELCH_THRESHOLD:
|
||||
|
||||
156
src/main/java/org/jtransforms/fft/ReusableFloatInverseFFT.java
Normal file
156
src/main/java/org/jtransforms/fft/ReusableFloatInverseFFT.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.audio.playback;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for audio provider sample conversion
|
||||
*/
|
||||
class AudioProviderPcmTest
|
||||
{
|
||||
@Test
|
||||
void outOfRangeSamplesAreLimitedInsteadOfWrapping()
|
||||
{
|
||||
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.35f));
|
||||
assertEquals(-Short.MAX_VALUE, AudioProvider.toPcm(-26.0f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inRangeSamplesAreScaled()
|
||||
{
|
||||
assertEquals(0, AudioProvider.toPcm(0.0f));
|
||||
assertEquals(16383, AudioProvider.toPcm(0.5f));
|
||||
assertEquals(Short.MAX_VALUE, AudioProvider.toPcm(1.0f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the AM audio normalizer
|
||||
*/
|
||||
class AmAudioNormalizerTest
|
||||
{
|
||||
private static final double SAMPLE_RATE = 25000.0;
|
||||
private static final float MODULATION = 0.8f;
|
||||
|
||||
private static float envelope(float carrier, int sample)
|
||||
{
|
||||
return carrier * (1.0f + MODULATION * (float)Math.cos(2.0 * Math.PI * 1000.0 / SAMPLE_RATE * sample));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outputFollowsModulationDepthAtAnyCarrierLevel()
|
||||
{
|
||||
for(float carrier: new float[]{0.0005f, 0.002f, 0.01f, 0.03f, 0.1f})
|
||||
{
|
||||
checkOutput(carrier);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkOutput(float carrier)
|
||||
{
|
||||
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
|
||||
double sum = 0.0;
|
||||
float peak = 0.0f;
|
||||
int settle = (int)SAMPLE_RATE;
|
||||
int count = (int)SAMPLE_RATE;
|
||||
|
||||
for(int x = 0; x < settle + count; x++)
|
||||
{
|
||||
float audio = normalizer.process(envelope(carrier, x));
|
||||
|
||||
if(x >= settle)
|
||||
{
|
||||
sum += audio;
|
||||
peak = Math.max(peak, Math.abs(audio));
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(0.6f, peak, 0.02f, "Peak");
|
||||
assertEquals(0.0, sum / count, 0.002, "DC");
|
||||
}
|
||||
|
||||
@Test
|
||||
void transmissionStartHasNoTransient()
|
||||
{
|
||||
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
|
||||
|
||||
for(int x = 0; x < (int)SAMPLE_RATE; x++)
|
||||
{
|
||||
assertTrue(Math.abs(normalizer.process(envelope(0.01f, x))) < 0.8f, "Transient at sample " + x);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noiseAfterCarrierDropIsNotAmplifiedToCarrierLevel()
|
||||
{
|
||||
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
|
||||
|
||||
for(int x = 0; x < (int)SAMPLE_RATE; x++)
|
||||
{
|
||||
normalizer.process(envelope(0.01f, x));
|
||||
}
|
||||
|
||||
java.util.Random random = new java.util.Random(1);
|
||||
float peak = 0.0f;
|
||||
|
||||
//Noise is 40 dB below the carrier. Skip the carrier estimate decay.
|
||||
for(int x = 0; x < (int)(SAMPLE_RATE * 0.5); x++)
|
||||
{
|
||||
float audio = normalizer.process(0.0001f * (1.0f + random.nextFloat()));
|
||||
|
||||
if(x > (int)(SAMPLE_RATE * 0.2))
|
||||
{
|
||||
peak = Math.max(peak, Math.abs(audio));
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(peak < 0.05f, "Noise peak " + peak);
|
||||
}
|
||||
|
||||
@Test
|
||||
void outputIsLimited()
|
||||
{
|
||||
AmAudioNormalizer normalizer = new AmAudioNormalizer(SAMPLE_RATE);
|
||||
|
||||
for(int x = 0; x < 1000; x++)
|
||||
{
|
||||
normalizer.process(0.001f);
|
||||
}
|
||||
|
||||
assertTrue(normalizer.process(1.0f) <= 0.95f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.am;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the AM demodulator squelch transitions
|
||||
*/
|
||||
class SquelchingAMDemodulatorTest
|
||||
{
|
||||
private static final int SAMPLE_RATE = 25000;
|
||||
private static final int BUFFER = 1024;
|
||||
private static final double TONE = 1000.0;
|
||||
|
||||
/**
|
||||
* Demodulates silence, then a transmission, then silence.
|
||||
*/
|
||||
private static float[] transmission(float carrier)
|
||||
{
|
||||
SquelchingAMDemodulator demodulator = new SquelchingAMDemodulator(-78.0f, false, 6000.0);
|
||||
demodulator.setSampleRate(SAMPLE_RATE);
|
||||
int buffers = 150;
|
||||
float[] audio = new float[BUFFER * buffers];
|
||||
|
||||
for(int b = 0; b < buffers; b++)
|
||||
{
|
||||
float[] i = new float[BUFFER];
|
||||
float[] q = new float[BUFFER];
|
||||
|
||||
if(b >= 30 && b < 100)
|
||||
{
|
||||
for(int x = 0; x < BUFFER; x++)
|
||||
{
|
||||
int sample = b * BUFFER + x;
|
||||
i[x] = carrier * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * TONE * sample / SAMPLE_RATE));
|
||||
}
|
||||
}
|
||||
|
||||
System.arraycopy(demodulator.demodulate(i, q), 0, audio, b * BUFFER, BUFFER);
|
||||
}
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
@Test
|
||||
void transmissionHasNoClicks()
|
||||
{
|
||||
for(float carrier: new float[]{0.001f, 0.01f, 0.1f})
|
||||
{
|
||||
float[] audio = transmission(carrier);
|
||||
float peak = 0.0f;
|
||||
float step = 0.0f;
|
||||
|
||||
for(int x = 1; x < audio.length; x++)
|
||||
{
|
||||
peak = Math.max(peak, Math.abs(audio[x]));
|
||||
step = Math.max(step, Math.abs(audio[x] - audio[x - 1]));
|
||||
}
|
||||
|
||||
//Expected 80% modulated peak is 0.6 and the largest step of the 1 kHz tone is 0.15
|
||||
assertTrue(peak < 0.7f, "Peak " + peak + " for carrier " + carrier);
|
||||
assertTrue(step < 0.2f, "Step " + step + " for carrier " + carrier);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void audioEndsWithTheTransmission()
|
||||
{
|
||||
float[] audio = transmission(0.01f);
|
||||
int last = 0;
|
||||
|
||||
for(int x = 0; x < audio.length; x++)
|
||||
{
|
||||
if(audio[x] != 0.0f)
|
||||
{
|
||||
last = x;
|
||||
}
|
||||
}
|
||||
|
||||
//Audio is delayed by 32 ms, so it can't run past the end of the transmission plus the delay.
|
||||
int end = 100 * BUFFER;
|
||||
assertTrue(last <= end + SAMPLE_RATE * 0.032, "Last audio sample " + last + " for end at " + end);
|
||||
assertEquals(0.0f, audio[audio.length - 1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.filter;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the channel filter design
|
||||
*/
|
||||
class ChannelFilterDesignerTest
|
||||
{
|
||||
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 7500.0, 8333.0, 12500.0, 15000.0, 25000.0};
|
||||
|
||||
/**
|
||||
* Each channel filter must be valid across decimated sample rates, which includes rates where the remez designer
|
||||
* previously produced a filter with a large response spike at half of the sample rate.
|
||||
*/
|
||||
@Test
|
||||
void channelFilterResponseIsValidForDecimatedSampleRates()
|
||||
{
|
||||
for(double bandwidth: BANDWIDTHS)
|
||||
{
|
||||
for(double sampleRate = bandwidth; sampleRate < 4.0 * bandwidth; sampleRate += bandwidth / 32.0)
|
||||
{
|
||||
check(sampleRate, bandwidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void tunerDerivedSampleRates()
|
||||
{
|
||||
check(11718.75, 3000.0);
|
||||
check(19531.25, 5000.0);
|
||||
check(50000.0, 15000.0);
|
||||
check(29296.875, 7500.0);
|
||||
check(48828.125, 12500.0);
|
||||
check(25000.0, 7500.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* These sample rates use the fallback filter design, which must keep the same bandwidth as the remez design.
|
||||
*/
|
||||
@Test
|
||||
void fallbackFilterKeepsChannelBandwidth()
|
||||
{
|
||||
checkPassBand(11718.75, 3000.0);
|
||||
checkPassBand(19531.25, 5000.0);
|
||||
checkPassBand(50000.0, 15000.0);
|
||||
}
|
||||
|
||||
private static void checkPassBand(double sampleRate, double bandwidth)
|
||||
{
|
||||
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
|
||||
double response = ChannelFilterDesigner.getResponseDb(coefficients, bandwidth * .4, sampleRate) -
|
||||
ChannelFilterDesigner.getResponseDb(coefficients, 0.0, sampleRate);
|
||||
assertTrue(response > -1.0, "Pass band edge response [" + response + " dB] at sample rate [" + sampleRate + "]");
|
||||
}
|
||||
|
||||
private static void check(double sampleRate, double bandwidth)
|
||||
{
|
||||
float[] coefficients = ChannelFilterDesigner.design(sampleRate, bandwidth);
|
||||
assertTrue(ChannelFilterDesigner.isValidFilterResponse(coefficients, sampleRate, (int)(bandwidth * .4), (int)(bandwidth * .5)),
|
||||
"Filter response for sample rate [" + sampleRate + "] bandwidth [" + bandwidth + "]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseCheckRejectsSpikeAtNyquist()
|
||||
{
|
||||
//Low-pass moving average with an added alternating sequence that peaks at half of the sample rate.
|
||||
float[] coefficients = new float[31];
|
||||
|
||||
for(int x = 0; x < coefficients.length; x++)
|
||||
{
|
||||
coefficients[x] = 1.0f / coefficients.length + (x % 2 == 0 ? 0.1f : -0.1f);
|
||||
}
|
||||
|
||||
assertTrue(!ChannelFilterDesigner.isValidFilterResponse(coefficients, 25000.0, 3000, 4000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the adaptive squelch
|
||||
*/
|
||||
class AdaptiveSquelchTest
|
||||
{
|
||||
private static final int SAMPLE_RATE = 25000;
|
||||
private static final float THRESHOLD_DB = -78.0f;
|
||||
private static final float NOISE = (float)Math.pow(10.0, -9.0); //-90 dB
|
||||
|
||||
private static AdaptiveSquelch squelch(int sampleRate, boolean autoTrack)
|
||||
{
|
||||
AdaptiveSquelch squelch = new AdaptiveSquelch(THRESHOLD_DB, autoTrack, 6000.0);
|
||||
squelch.setSampleRate(sampleRate);
|
||||
return squelch;
|
||||
}
|
||||
|
||||
private static void process(AdaptiveSquelch squelch, float power, double seconds, int sampleRate)
|
||||
{
|
||||
for(int x = 0; x < (int)(seconds * sampleRate); x++)
|
||||
{
|
||||
squelch.process(power);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds of samples at the power level until the audio is disabled and until the squelch closes.
|
||||
*/
|
||||
private static double[] closeTimes(float signal, int sampleRate)
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(sampleRate, false);
|
||||
process(squelch, signal, 0.5, sampleRate);
|
||||
assertTrue(squelch.isUnmuted());
|
||||
double audio = -1.0;
|
||||
|
||||
for(int x = 0; x < sampleRate; x++)
|
||||
{
|
||||
squelch.process(NOISE);
|
||||
|
||||
if(audio < 0.0 && !squelch.isAudioEnabled())
|
||||
{
|
||||
audio = (double)x / sampleRate;
|
||||
}
|
||||
|
||||
if(squelch.isMuted())
|
||||
{
|
||||
return new double[]{audio, (double)x / sampleRate};
|
||||
}
|
||||
}
|
||||
|
||||
throw new AssertionError("Squelch didn't close");
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeTimeIsIndependentOfSignalLevelAndSampleRate()
|
||||
{
|
||||
for(int sampleRate: new int[]{6250, 25000, 50000})
|
||||
{
|
||||
for(float signal: new float[]{1E-7f, 1E-6f, 1E-4f, 1E-2f})
|
||||
{
|
||||
double[] times = closeTimes(signal, sampleRate);
|
||||
assertEquals(0.0175, times[0], 0.0085, "Audio disable " + signal + " at " + sampleRate);
|
||||
assertEquals(0.1175, times[1], 0.0085, "Close " + signal + " at " + sampleRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsMuted()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
assertTrue(squelch.isMuted());
|
||||
assertFalse(squelch.isAudioEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shortNoiseBurstDoesNotOpen()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
process(squelch, NOISE, 0.1, SAMPLE_RATE);
|
||||
process(squelch, 1E-4f, 0.005, SAMPLE_RATE);
|
||||
process(squelch, NOISE, 0.1, SAMPLE_RATE);
|
||||
assertTrue(squelch.isMuted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hysteresisHoldsOpenBelowOpenThreshold()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
|
||||
process(squelch, threshold * 1.2f, 0.1, SAMPLE_RATE);
|
||||
assertTrue(squelch.isUnmuted());
|
||||
process(squelch, threshold * 0.6f, 2.0, SAMPLE_RATE); //2 dB below open, above close
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fadeShorterThanHangKeepsCallOpen()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
process(squelch, 1E-5f, 0.1, SAMPLE_RATE);
|
||||
process(squelch, NOISE, 0.06, SAMPLE_RATE);
|
||||
assertFalse(squelch.isAudioEnabled());
|
||||
assertTrue(squelch.isUnmuted());
|
||||
process(squelch, 1E-5f, 0.03, SAMPLE_RATE);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lowFrequencyModulationDoesNotDisableAudio()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
float threshold = (float)Math.pow(10.0, THRESHOLD_DB / 10.0);
|
||||
process(squelch, threshold * 2.0f, 0.1, SAMPLE_RATE);
|
||||
|
||||
//Carrier 3 dB over the open threshold with 100% modulation at 100 Hz
|
||||
for(int x = 0; x < SAMPLE_RATE * 2; x++)
|
||||
{
|
||||
double envelope = 1.0 + Math.cos(2.0 * Math.PI * 100.0 * x / SAMPLE_RATE);
|
||||
squelch.process((float)(threshold * 2.0 / 1.5 * envelope * envelope));
|
||||
assertTrue(squelch.isAudioEnabled(), "Audio disabled at sample " + x);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample powers with the exponential distribution of complex gaussian noise.
|
||||
*/
|
||||
private static void noise(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
|
||||
{
|
||||
for(int x = 0; x < (int)(seconds * sampleRate); x++)
|
||||
{
|
||||
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * power));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample powers for a steady carrier with noise 20 dB below the carrier
|
||||
*/
|
||||
private static void carrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
|
||||
{
|
||||
double sigma = Math.sqrt(power * 0.01 / 2.0);
|
||||
double amplitude = Math.sqrt(power);
|
||||
|
||||
for(int x = 0; x < (int)(seconds * sampleRate); x++)
|
||||
{
|
||||
double i = amplitude + sigma * random.nextGaussian();
|
||||
double q = sigma * random.nextGaussian();
|
||||
squelch.process((float)(i * i + q * q));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdBelowNoiseFloorDoesNotLatchOpen()
|
||||
{
|
||||
Random random = new Random(1);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
float noise = (float)Math.pow(10.0, -7.4); //-74 dB is above the -78 dB threshold
|
||||
|
||||
for(int x = 0; x < SAMPLE_RATE * 30; x++)
|
||||
{
|
||||
squelch.process((float)(-Math.log(1.0 - random.nextDouble()) * noise));
|
||||
assertTrue(squelch.isMuted(), "Opened at sample " + x);
|
||||
}
|
||||
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transmissionOpensOverLearnedNoiseFloor()
|
||||
{
|
||||
Random random = new Random(2);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
|
||||
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random); //-64 dB
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
noise(squelch, 4E-8f, 0.5, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userMarginAboveMinimumIsKept()
|
||||
{
|
||||
Random random = new Random(3);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB, with a -78 dB threshold
|
||||
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoTrackKeepsDecibelMarginWhenNoiseFloorRises()
|
||||
{
|
||||
Random random = new Random(4);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 1E-9f, 2.0, SAMPLE_RATE, random); //-90 dB
|
||||
|
||||
//Raise the noise floor 16 dB in steps that stay under the squelch threshold.
|
||||
for(int step = 1; step <= 8; step++)
|
||||
{
|
||||
noise(squelch, 1E-9f * (float)Math.pow(10.0, step * 0.2), 11.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted(), "Opened at step " + step);
|
||||
}
|
||||
|
||||
assertEquals(THRESHOLD_DB + 16.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void steadyCarrierPresentAtStartPassesUntilTimeoutThenMutes()
|
||||
{
|
||||
Random random = new Random(5);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
carrier(squelch, 2.5E-7f, 59.0, SAMPLE_RATE, random); //-66 dB
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
carrier(squelch, 2.5E-7f, 3.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
carrier(squelch, 2.5E-7f, 30.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void steadyCarrierAfterNoisePassesUntilTimeoutThenMutes()
|
||||
{
|
||||
Random random = new Random(6);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
|
||||
carrier(squelch, 4E-7f, 59.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
carrier(squelch, 4E-7f, 3.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transmissionsAreHeardAfterCarrierTimeoutEnds()
|
||||
{
|
||||
Random random = new Random(7);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
|
||||
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
|
||||
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
carrier(squelch, 4E-7f, 1.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoTrackDisabledUsesUserThreshold()
|
||||
{
|
||||
Random random = new Random(8);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //Noise above the threshold opens the squelch
|
||||
assertTrue(squelch.isUnmuted());
|
||||
assertEquals(THRESHOLD_DB, squelch.getSquelchThreshold(), 0.01f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the threshold and returns the user threshold notification value.
|
||||
*/
|
||||
private static double requestThreshold(AdaptiveSquelch squelch, double threshold)
|
||||
{
|
||||
List<SourceEvent> events = new ArrayList<>();
|
||||
squelch.setSourceEventListener(events::add);
|
||||
squelch.receive(SourceEvent.requestSquelchThreshold(null, threshold));
|
||||
squelch.setSourceEventListener(null);
|
||||
return events.stream().filter(event -> event.getEvent() == SourceEvent.Event.NOTIFICATION_SQUELCH_USER_THRESHOLD)
|
||||
.findFirst().orElseThrow().getValue().doubleValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdChangeDuringTimedOutCarrierIsRelativeToNoiseFloor()
|
||||
{
|
||||
Random random = new Random(9);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB with a -68 dB threshold
|
||||
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random); //-64 dB with a -58 dB threshold
|
||||
assertEquals(-58.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
|
||||
//One dB up from the displayed threshold saves one dB over the -68 dB threshold, not -57 dB
|
||||
assertEquals(-67.0, requestThreshold(squelch, squelch.getSquelchThreshold() + 1.0), 0.5);
|
||||
assertEquals(-57.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
|
||||
//The setting applies when the carrier ends
|
||||
noise(squelch, 4E-8f, 1.0, SAMPLE_RATE, random);
|
||||
assertEquals(-67.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdChangeBelowMinimumMarginIsLimited()
|
||||
{
|
||||
Random random = new Random(10);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random); //-74 dB
|
||||
assertEquals(-68.0, requestThreshold(squelch, -72.0), 0.5);
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thresholdChangeWithAutoTrackDisabledIsUnchanged()
|
||||
{
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, false);
|
||||
assertEquals(-72.0, requestThreshold(squelch, -72.0), 0.01);
|
||||
assertEquals(-72.0f, squelch.getSquelchThreshold(), 0.01f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample powers for a carrier with noise 20 dB below the carrier and bursts of low frequency voice-like modulation.
|
||||
*/
|
||||
private static void voiceCarrier(AdaptiveSquelch squelch, float power, double seconds, int sampleRate, Random random)
|
||||
{
|
||||
double sigma = Math.sqrt(power * 0.01 / 2.0);
|
||||
double amplitude = Math.sqrt(power);
|
||||
|
||||
for(int x = 0; x < (int)(seconds * sampleRate); x++)
|
||||
{
|
||||
double time = (double)x / sampleRate;
|
||||
double modulation = 0.3 * Math.cos(2.0 * Math.PI * 500.0 * time) + 0.2 * Math.cos(2.0 * Math.PI * 800.0 * time);
|
||||
double i = amplitude * (1.0 + modulation) + sigma * random.nextGaussian();
|
||||
double q = sigma * random.nextGaussian();
|
||||
squelch.process((float)(i * i + q * q));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void carrierWithVoiceModulationDoesNotTimeOut()
|
||||
{
|
||||
Random random = new Random(11);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
|
||||
voiceCarrier(squelch, 4E-7f, 190.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void voiceOnTimedOutCarrierReopensSquelch()
|
||||
{
|
||||
Random random = new Random(12);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
noise(squelch, 4E-8f, 2.0, SAMPLE_RATE, random);
|
||||
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
|
||||
assertEquals(-68.0f, squelch.getSquelchThreshold(), 0.5f);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voiceOnCarrierPresentAtStartReopensSquelch()
|
||||
{
|
||||
Random random = new Random(13);
|
||||
AdaptiveSquelch squelch = squelch(SAMPLE_RATE, true);
|
||||
carrier(squelch, 4E-7f, 63.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isMuted());
|
||||
voiceCarrier(squelch, 4E-7f, 4.0, SAMPLE_RATE, random);
|
||||
assertTrue(squelch.isAudioEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.dsp.squelch;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the voice modulation detector
|
||||
*/
|
||||
class VoiceModulationDetectorTest
|
||||
{
|
||||
private static final double[] BANDWIDTHS = {3000.0, 5000.0, 6000.0, 8333.0, 15000.0, 25000.0};
|
||||
|
||||
/**
|
||||
* Fills the detector with a carrier having white envelope noise and optional modulation.
|
||||
*/
|
||||
private static boolean analyze(double sampleRate, double bandwidth, double modulation, Random random)
|
||||
{
|
||||
int samples = (int)(sampleRate / 2.0);
|
||||
VoiceModulationDetector detector = new VoiceModulationDetector(sampleRate, bandwidth, samples);
|
||||
|
||||
for(int x = 0; x < samples; x++)
|
||||
{
|
||||
double time = x / sampleRate;
|
||||
detector.receive((float)(1.0 + modulation * Math.cos(2.0 * Math.PI * 450.0 * time) + 0.1 * random.nextGaussian()));
|
||||
}
|
||||
|
||||
return detector.analyze();
|
||||
}
|
||||
|
||||
@Test
|
||||
void flatEnvelopeNoiseIsNotVoice()
|
||||
{
|
||||
Random random = new Random(1);
|
||||
|
||||
for(double bandwidth: BANDWIDTHS)
|
||||
{
|
||||
for(double sampleRate: new double[]{25000.0, 39062.5, 50000.0})
|
||||
{
|
||||
for(int x = 0; x < 50; x++)
|
||||
{
|
||||
assertFalse(analyze(sampleRate, bandwidth, 0.0, random), "Bandwidth " + bandwidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lowFrequencyModulationIsVoice()
|
||||
{
|
||||
Random random = new Random(2);
|
||||
|
||||
for(double bandwidth: BANDWIDTHS)
|
||||
{
|
||||
assertTrue(analyze(39062.5, bandwidth, 0.2, random), "Bandwidth " + bandwidth);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialBufferIsNotAnalyzed()
|
||||
{
|
||||
VoiceModulationDetector detector = new VoiceModulationDetector(25000.0, 6000.0, 12500);
|
||||
detector.receive(1.0f);
|
||||
assertFalse(detector.analyze());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.module.decode.am;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import io.github.dsheirer.dsp.filter.decimate.DecimationFilterFactory;
|
||||
import io.github.dsheirer.module.decode.analog.DecodeConfigAnalog.Bandwidth;
|
||||
import io.github.dsheirer.sample.complex.ComplexSamples;
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import io.github.dsheirer.source.tuner.channel.ChannelSpecification;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the channel sample rates supported by the AM decoder.
|
||||
*/
|
||||
class AMDecoderSampleRateTest
|
||||
{
|
||||
private static final int BUFFER = 2048;
|
||||
|
||||
private static AMDecoder decoder(Bandwidth bandwidth, double sampleRate)
|
||||
{
|
||||
DecodeConfigAM config = new DecodeConfigAM();
|
||||
config.setBandwidth(bandwidth);
|
||||
AMDecoder decoder = new AMDecoder(config);
|
||||
decoder.getSourceEventListener().receive(SourceEvent.sampleRateChange(sampleRate));
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultBandwidthIsSixKilohertz()
|
||||
{
|
||||
assertEquals(Bandwidth.BW_6_0, new DecodeConfigAM().getBandwidth());
|
||||
assertTrue(Bandwidth.BW_6_0.isAM());
|
||||
}
|
||||
|
||||
@Test
|
||||
void minimumChannelSampleRateIsAtLeastTwiceTheBandwidth()
|
||||
{
|
||||
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
|
||||
{
|
||||
DecodeConfigAM config = new DecodeConfigAM();
|
||||
config.setBandwidth(bandwidth);
|
||||
assertTrue(config.getChannelSpecification().getMinimumSampleRate() >= 2.0 * bandwidth.getValue(), bandwidth.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows the heterodyne channel source decimation selection for tuner sample rates, including 1.8 and 3.6 MHz which
|
||||
* previously produced an unsupported 28,125 Hz sample rate for the default 15 kHz bandwidth.
|
||||
*/
|
||||
@Test
|
||||
void heterodyneChannelRatesAreSupported()
|
||||
{
|
||||
double[] tunerRates = {1.8E6, 2.0E6, 2.4E6, 2.56E6, 3.0E6, 3.2E6, 3.6E6, 6.0E6, 7.2E6, 8.0E6, 10.0E6, 20.0E6};
|
||||
|
||||
for(Bandwidth bandwidth: Bandwidth.AM_BANDWIDTHS)
|
||||
{
|
||||
DecodeConfigAM config = new DecodeConfigAM();
|
||||
config.setBandwidth(bandwidth);
|
||||
ChannelSpecification specification = config.getChannelSpecification();
|
||||
|
||||
for(double tunerRate: tunerRates)
|
||||
{
|
||||
int decimation = DecimationFilterFactory.getDecimationRate((int)(tunerRate / specification.getMinimumSampleRate()));
|
||||
decoder(bandwidth, tunerRate / decimation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyphase channel rates don't use the channel specification minimum rate.
|
||||
*/
|
||||
@Test
|
||||
void ratesBelowTwiceTheBandwidthDecodeAudio()
|
||||
{
|
||||
for(double sampleRate: new double[]{25000.0, 28125.0})
|
||||
{
|
||||
AMDecoder decoder = decoder(Bandwidth.BW_15_0, sampleRate);
|
||||
List<float[]> audio = new ArrayList<>();
|
||||
decoder.setBufferListener(audio::add);
|
||||
int sample = 0;
|
||||
|
||||
for(int b = 0; b < (int)(sampleRate * 2.0 / BUFFER); b++)
|
||||
{
|
||||
float[] i = new float[BUFFER];
|
||||
float[] q = new float[BUFFER];
|
||||
|
||||
for(int x = 0; x < BUFFER; x++)
|
||||
{
|
||||
i[x] = 0.01f * (1.0f + 0.8f * (float)Math.cos(2.0 * Math.PI * 1000.0 * sample++ / sampleRate));
|
||||
}
|
||||
|
||||
decoder.receive(new ComplexSamples(i, q, 0L));
|
||||
}
|
||||
|
||||
float peak = 0.0f;
|
||||
|
||||
for(float[] buffer: audio.subList(audio.size() / 2, audio.size()))
|
||||
{
|
||||
for(float value: buffer)
|
||||
{
|
||||
peak = Math.max(peak, Math.abs(value));
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(0.6f, peak, 0.05f, "Peak at sample rate " + sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateBelowTheBandwidthIsRejected()
|
||||
{
|
||||
assertThrows(IllegalStateException.class, () -> decoder(Bandwidth.BW_25_0, 20000.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* *****************************************************************************
|
||||
* Copyright (C) 2014-2026 Dennis Sheirer
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
* ****************************************************************************
|
||||
*/
|
||||
package io.github.dsheirer.module.decode.am;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import io.github.dsheirer.source.SourceEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for the source event requests that the squelch controls use to get the current squelch settings.
|
||||
*/
|
||||
class AMDecoderSquelchRequestTest
|
||||
{
|
||||
private static List<SourceEvent> events(DecodeConfigAM config, SourceEvent request)
|
||||
{
|
||||
AMDecoder decoder = new AMDecoder(config);
|
||||
List<SourceEvent> events = new ArrayList<>();
|
||||
decoder.setSourceEventListener(events::add);
|
||||
decoder.getSourceEventListener().receive(request);
|
||||
return events;
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentThresholdRequestIsAnswered()
|
||||
{
|
||||
DecodeConfigAM config = new DecodeConfigAM();
|
||||
config.setSquelchThreshold(-70);
|
||||
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchThreshold());
|
||||
assertEquals(1, events.size());
|
||||
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_THRESHOLD, events.getFirst().getEvent());
|
||||
assertEquals(-70.0, events.getFirst().getValue().doubleValue(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentAutoTrackRequestIsAnswered()
|
||||
{
|
||||
DecodeConfigAM config = new DecodeConfigAM();
|
||||
config.setSquelchAutoTrack(false);
|
||||
List<SourceEvent> events = events(config, SourceEvent.requestCurrentSquelchAutoTrack());
|
||||
assertEquals(1, events.size());
|
||||
assertEquals(SourceEvent.Event.NOTIFICATION_SQUELCH_AUTO_TRACK, events.getFirst().getEvent());
|
||||
assertEquals(0, events.getFirst().getValue().intValue());
|
||||
}
|
||||
}
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user