diff --git a/build.gradle b/build.gradle index ba9b7e5c..b176f509 100644 --- a/build.gradle +++ b/build.gradle @@ -109,7 +109,7 @@ dependencies { implementation 'javax.usb:usb-api:1.0.2' implementation 'net.coderazzi:tablefilter-swing:5.5.4' implementation 'org.apache.commons:commons-compress:1.27.1' - implementation 'org.apache.commons:commons-lang3:3.17.0' + implementation 'org.apache.commons:commons-lang3:3.18.0' implementation 'org.apache.commons:commons-math3:3.6.1' implementation 'org.apache.commons:commons-csv:1.14.0' implementation 'org.apache.mina:mina-core:2.2.4' diff --git a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyBufferIterator.java b/src/main/java/io/github/dsheirer/buffer/airspy/AirspyBufferIterator.java index cd035cd4..cc4fc807 100644 --- a/src/main/java/io/github/dsheirer/buffer/airspy/AirspyBufferIterator.java +++ b/src/main/java/io/github/dsheirer/buffer/airspy/AirspyBufferIterator.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2022 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -30,7 +30,7 @@ import java.util.Iterator; public abstract class AirspyBufferIterator implements Iterator { protected static final float SCALE_SIGNED_12_BIT_TO_FLOAT = 1.0f / 2048.0f; - protected static final int FRAGMENT_SIZE = 2048; + protected static final int FRAGMENT_SIZE = 4096; protected static final float[] COEFFICIENTS = HilbertTransform.convertHalfBandToHilbert(HilbertTransform.HALF_BAND_FILTER_47_TAP); diff --git a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java index 963f73c3..b6933576 100644 --- a/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java +++ b/src/main/java/io/github/dsheirer/channel/metadata/NowPlayingPanel.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -21,7 +21,7 @@ package io.github.dsheirer.channel.metadata; import com.jidesoft.swing.JideSplitPane; import com.jidesoft.swing.JideTabbedPane; import io.github.dsheirer.channel.details.ChannelDetailPanel; -import io.github.dsheirer.gui.power.ChannelPowerPanel; +import io.github.dsheirer.gui.channel.ChannelSpectrumPanel; import io.github.dsheirer.icon.IconModel; import io.github.dsheirer.module.decode.event.DecodeEventPanel; import io.github.dsheirer.module.decode.event.MessageActivityPanel; @@ -42,7 +42,7 @@ public class NowPlayingPanel extends JPanel private final ChannelDetailPanel mChannelDetailPanel; private final DecodeEventPanel mDecodeEventPanel; private final MessageActivityPanel mMessageActivityPanel; - private final ChannelPowerPanel mChannelPowerPanel; + private final ChannelSpectrumPanel mChannelSpectrumSquelchPanel; private JideTabbedPane mTabbedPane; private JideSplitPane mSplitPane; private boolean mDetailTabsVisible; @@ -58,7 +58,7 @@ public class NowPlayingPanel extends JPanel mDecodeEventPanel = new DecodeEventPanel(iconModel, userPreferences, playlistManager.getAliasModel()); mMessageActivityPanel = new MessageActivityPanel(userPreferences); mChannelMetadataPanel = new ChannelMetadataPanel(playlistManager, iconModel, userPreferences); - mChannelPowerPanel = new ChannelPowerPanel(playlistManager, settingsManager); + mChannelSpectrumSquelchPanel = new ChannelSpectrumPanel(playlistManager, settingsManager); mDetailTabsVisible = detailTabsVisible; init(); @@ -96,12 +96,12 @@ public class NowPlayingPanel extends JPanel mTabbedPane.addTab("Details", mChannelDetailPanel); mTabbedPane.addTab("Events", mDecodeEventPanel); mTabbedPane.addTab("Messages", mMessageActivityPanel); - mTabbedPane.addTab("Channel", mChannelPowerPanel); + mTabbedPane.addTab("Channel", mChannelSpectrumSquelchPanel); mTabbedPane.setFont(this.getFont()); mTabbedPane.setForeground(Color.BLACK); //Register state change listener to toggle visibility state for channel tab to turn-on/off FFT processing - mTabbedPane.addChangeListener(e -> mChannelPowerPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane() - .indexOfComponent(mChannelPowerPanel))); + mTabbedPane.addChangeListener(e -> mChannelSpectrumSquelchPanel.setPanelVisible(getTabbedPane().getSelectedIndex() == getTabbedPane() + .indexOfComponent(mChannelSpectrumSquelchPanel))); } return mTabbedPane; @@ -135,6 +135,6 @@ public class NowPlayingPanel extends JPanel mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelDetailPanel); mChannelMetadataPanel.addProcessingChainSelectionListener(mDecodeEventPanel); mChannelMetadataPanel.addProcessingChainSelectionListener(mMessageActivityPanel); - mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelPowerPanel); + mChannelMetadataPanel.addProcessingChainSelectionListener(mChannelSpectrumSquelchPanel); } } diff --git a/src/main/java/io/github/dsheirer/dsp/filter/design/FilterViewer.java b/src/main/java/io/github/dsheirer/dsp/filter/design/FilterViewer.java index 7dd7ac32..3771675c 100644 --- a/src/main/java/io/github/dsheirer/dsp/filter/design/FilterViewer.java +++ b/src/main/java/io/github/dsheirer/dsp/filter/design/FilterViewer.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2024 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -58,11 +58,11 @@ public class FilterViewer extends Application */ private float[] getFilter() { - int filterLength = 47; - WindowType windowType = WindowType.HAMMING; + int filterLength = 31; + WindowType windowType = WindowType.BLACKMAN_HARRIS_7; -// float[] taps = FilterFactory.getHalfBand(filterLength, windowType); - float[] taps = FilterFactory.getRootRaisedCosine(50000.0 / 4800.0, 20, 0.2f); + float[] taps = FilterFactory.getHighPass(8000, 3000, filterLength, windowType); +// float[] taps = FilterFactory.getRootRaisedCosine(50000.0 / 4800.0, 20, 0.2f); // taps = HilbertTransform.HALF_BAND_FILTER_47_TAP; diff --git a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator.java b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator.java index 1ecb865b..1b3a02f3 100644 --- a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator.java +++ b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,10 +19,6 @@ package io.github.dsheirer.dsp.fm; -import io.github.dsheirer.vector.VectorUtilities; -import jdk.incubator.vector.FloatVector; -import jdk.incubator.vector.VectorOperators; -import jdk.incubator.vector.VectorSpecies; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,63 +28,22 @@ import org.slf4j.LoggerFactory; public abstract class VectorFMDemodulator implements IDemodulator { private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator.class); - protected static final float ZERO = 0.0f; - private static final int BUFFER_OVERLAP = 1; - - //Initialize buffers to be non-null and resize once the first buffer arrives - private float[] mIBuffer = new float[1]; - private float[] mQBuffer = new float[1]; + private ScalarFMDemodulator mScalar; public VectorFMDemodulator() { } /** - * Vector species as specified by the implementing sub-class. + * Access the scalar implementation when the sample buffer size is not a multiple of the vector size. */ - protected abstract VectorSpecies getVectorSpecies(); - - @Override public float[] demodulate(float[] i, float[] q) + protected ScalarFMDemodulator getScalarImplementation() { -// VectorSpecies species= getVectorSpecies(); - - VectorUtilities.checkComplexArrayLength(i, q, getVectorSpecies()); - - if(mIBuffer.length != (i.length + BUFFER_OVERLAP)) + if(mScalar == null) { - mIBuffer = new float[i.length + BUFFER_OVERLAP]; - mQBuffer = new float[q.length + BUFFER_OVERLAP]; + mScalar = new ScalarFMDemodulator(); } - //Copy last sample to beginning of buffer arrays - mIBuffer[0] = mIBuffer[mIBuffer.length - 1]; - mQBuffer[0] = mQBuffer[mQBuffer.length - 1]; - - //Copy new samples to end of buffer arrays - System.arraycopy(i, 0, mIBuffer, 1, i.length); - System.arraycopy(q, 0, mQBuffer, 1, q.length); - - float[] demodulated = new float[i.length]; - - FloatVector currentI, currentQ, previousI, previousQ, demod, demodI, demodQ; - - for(int bufferPointer = 0; bufferPointer < mIBuffer.length - 1; bufferPointer += getVectorSpecies().length()) - { - previousI = FloatVector.fromArray(getVectorSpecies(), mIBuffer, bufferPointer); - previousQ = FloatVector.fromArray(getVectorSpecies(), mQBuffer, bufferPointer); - currentI = FloatVector.fromArray(getVectorSpecies(), mIBuffer, bufferPointer + 1); - currentQ = FloatVector.fromArray(getVectorSpecies(), mQBuffer, bufferPointer + 1); - - demodI = currentI.mul(previousI).sub(currentQ.mul(previousQ.neg())); - demodQ = currentQ.mul(previousI).add(currentI.mul(previousQ.neg())); - - //Replace any zero-values in the I vector by adding Float.MIN_VALUE to side-step divide by zero errors - demodI = demodI.add(Float.MIN_VALUE, demodQ.eq(ZERO)); - - demod = demodQ.div(demodI).lanewise(VectorOperators.ATAN); - demod.intoArray(demodulated, bufferPointer); - } - - return demodulated; + return mScalar; } } diff --git a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator128.java b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator128.java index 9ad93591..89a6dada 100644 --- a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator128.java +++ b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator128.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,7 +19,6 @@ package io.github.dsheirer.dsp.fm; -import io.github.dsheirer.vector.VectorUtilities; import jdk.incubator.vector.FloatVector; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; @@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory; /** * FM demodulator that uses JDK 17+ SIMD vector intrinsics */ -public class VectorFMDemodulator128 implements IDemodulator +public class VectorFMDemodulator128 extends VectorFMDemodulator { private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator128.class); protected static final float ZERO = 0.0f; @@ -44,9 +43,14 @@ public class VectorFMDemodulator128 implements IDemodulator { } - @Override public float[] demodulate(float[] i, float[] q) + @Override + public float[] demodulate(float[] i, float[] q) { - VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES); + //Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation. + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i, q); + } if(mIBuffer.length != (i.length + BUFFER_OVERLAP)) { diff --git a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator256.java b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator256.java index fee855ae..3606bb82 100644 --- a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator256.java +++ b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator256.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,7 +19,6 @@ package io.github.dsheirer.dsp.fm; -import io.github.dsheirer.vector.VectorUtilities; import jdk.incubator.vector.FloatVector; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; @@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory; /** * FM demodulator that uses JDK 17+ SIMD vector intrinsics */ -public class VectorFMDemodulator256 implements IDemodulator +public class VectorFMDemodulator256 extends VectorFMDemodulator { private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator256.class); protected static final float ZERO = 0.0f; @@ -44,9 +43,14 @@ public class VectorFMDemodulator256 implements IDemodulator { } - @Override public float[] demodulate(float[] i, float[] q) + @Override + public float[] demodulate(float[] i, float[] q) { - VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES); + //Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation. + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } if(mIBuffer.length != (i.length + BUFFER_OVERLAP)) { diff --git a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator512.java b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator512.java index 644a501c..80642f9c 100644 --- a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator512.java +++ b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator512.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,7 +19,6 @@ package io.github.dsheirer.dsp.fm; -import io.github.dsheirer.vector.VectorUtilities; import jdk.incubator.vector.FloatVector; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; @@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory; /** * FM demodulator that uses JDK 17+ SIMD vector intrinsics */ -public class VectorFMDemodulator512 implements IDemodulator +public class VectorFMDemodulator512 extends VectorFMDemodulator { private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator512.class); protected static final float ZERO = 0.0f; @@ -44,9 +43,14 @@ public class VectorFMDemodulator512 implements IDemodulator { } - @Override public float[] demodulate(float[] i, float[] q) + @Override + public float[] demodulate(float[] i, float[] q) { - VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES); + //Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation. + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } if(mIBuffer.length != (i.length + BUFFER_OVERLAP)) { diff --git a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator64.java b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator64.java index 20fb1ffa..81206275 100644 --- a/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator64.java +++ b/src/main/java/io/github/dsheirer/dsp/fm/VectorFMDemodulator64.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,7 +19,6 @@ package io.github.dsheirer.dsp.fm; -import io.github.dsheirer.vector.VectorUtilities; import jdk.incubator.vector.FloatVector; import jdk.incubator.vector.VectorOperators; import jdk.incubator.vector.VectorSpecies; @@ -29,7 +28,7 @@ import org.slf4j.LoggerFactory; /** * FM demodulator that uses JDK 17+ SIMD vector intrinsics */ -public class VectorFMDemodulator64 implements IDemodulator +public class VectorFMDemodulator64 extends VectorFMDemodulator { private static final Logger mLog = LoggerFactory.getLogger(VectorFMDemodulator64.class); protected static final float ZERO = 0.0f; @@ -44,9 +43,14 @@ public class VectorFMDemodulator64 implements IDemodulator { } - @Override public float[] demodulate(float[] i, float[] q) + @Override + public float[] demodulate(float[] i, float[] q) { - VectorUtilities.checkComplexArrayLength(i, q, VECTOR_SPECIES); + //Fallback to scalar implementation if the buffer size is not a multiple of the vector implementation. + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } if(mIBuffer.length != (i.length + BUFFER_OVERLAP)) { diff --git a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulator.java b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulator.java index ffb8fc67..53f0ea88 100644 --- a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulator.java +++ b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulator.java @@ -37,6 +37,7 @@ public abstract class DifferentialDemodulator implements IDemodulator protected int mBufferOverlap; protected int mInterpolationOffset; protected final Interpolator mInterpolator = InterpolatorFactory.getInterpolator(); + private DifferentialDemodulatorScalar mScalar; /** * Constructor @@ -59,6 +60,20 @@ public abstract class DifferentialDemodulator implements IDemodulator } } + /** + * Scalar implementation for fallback when the sample buffer size is not compatible with the vector implementation. + * @return scalar implementation lazy instantiated when needed. + */ + protected DifferentialDemodulatorScalar getScalarImplementation() + { + if(mScalar == null) + { + mScalar = new DifferentialDemodulatorScalar(mSampleRate, mSymbolRate); + } + + return mScalar; + } + /** * Calculated samples per symbol. * @return samples per symbol. diff --git a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector128.java b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector128.java index 391bda1a..f98829ee 100644 --- a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector128.java +++ b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector128.java @@ -48,6 +48,11 @@ public class DifferentialDemodulatorVector128 extends DifferentialDemodulator @Override public float[] demodulate(float[] i, float[] q) { + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } + int sampleLength = i.length; int bufferOverlap = mBufferOverlap; float mu = mMu; @@ -60,10 +65,6 @@ public class DifferentialDemodulatorVector128 extends DifferentialDemodulator int requiredBufferLength = sampleLength + bufferOverlap; if(mIBuffer.length != requiredBufferLength) { - if(i.length % VECTOR_SPECIES.length() != 0) - { - throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length()); - } mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength); mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength); } diff --git a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector256.java b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector256.java index 2c47304b..ab82190a 100644 --- a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector256.java +++ b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector256.java @@ -45,6 +45,11 @@ public class DifferentialDemodulatorVector256 extends DifferentialDemodulator @Override public float[] demodulate(float[] i, float[] q) { + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } + int sampleLength = i.length; int bufferOverlap = mBufferOverlap; float mu = mMu; @@ -57,10 +62,6 @@ public class DifferentialDemodulatorVector256 extends DifferentialDemodulator int requiredBufferLength = sampleLength + bufferOverlap; if(mIBuffer.length != requiredBufferLength) { - if(i.length % VECTOR_SPECIES.length() != 0) - { - throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length()); - } mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength); mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength); } diff --git a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector512.java b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector512.java index 185d8a80..8108b8d8 100644 --- a/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector512.java +++ b/src/main/java/io/github/dsheirer/dsp/psk/demod/DifferentialDemodulatorVector512.java @@ -47,6 +47,11 @@ public class DifferentialDemodulatorVector512 extends DifferentialDemodulator @Override public float[] demodulate(float[] i, float[] q) { + if(i.length % VECTOR_SPECIES.length() != 0) + { + return getScalarImplementation().demodulate(i,q); + } + int sampleLength = i.length; int bufferOverlap = mBufferOverlap; float mu = mMu; @@ -59,10 +64,6 @@ public class DifferentialDemodulatorVector512 extends DifferentialDemodulator int requiredBufferLength = sampleLength + bufferOverlap; if(mIBuffer.length != requiredBufferLength) { - if(i.length % VECTOR_SPECIES.length() != 0) - { - throw new IllegalArgumentException("Buffer length must be a multiple of " + VECTOR_SPECIES.length()); - } mIBuffer = Arrays.copyOf(mIBuffer, requiredBufferLength); mQBuffer = Arrays.copyOf(mQBuffer, requiredBufferLength); } diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/INoiseSquelchController.java b/src/main/java/io/github/dsheirer/dsp/squelch/INoiseSquelchController.java new file mode 100644 index 00000000..5c03831e --- /dev/null +++ b/src/main/java/io/github/dsheirer/dsp/squelch/INoiseSquelchController.java @@ -0,0 +1,54 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.dsp.squelch; + +import io.github.dsheirer.sample.Listener; + +/** + * Interface for a decoder to expose access to noise squelch controls and status registration endpoint(s). + */ +public interface INoiseSquelchController +{ + /** + * Registers the listener to receive noise squelch states every 10 milliseconds for UI display. + * @param listener to receive states or pass null to de-register a listener. + */ + void setNoiseSquelchStateListener(Listener listener); + + /** + * Sets the open and close noise thresholds in range 0.0 to 1.0, recommend: 0.1 open and 0.2 close + * @param open for the open noise variance calculation. + * @param close for the close noise variance calculation. + */ + void setNoiseThreshold(float open, float close); + + /** + * Sets the hysteresis threshold in units of 10 milliseconds + * @param open in range 1-10, recommend: 4 + * @param close in range 1-10, recommend: 6 + */ + void setHysteresisThreshold(int open, int close); + + /** + * Manual override of the squelch control to set an always un-squelch state (true) or automatic control (false). + * @param override (true) or (false) to turn off squelch override. + */ + void setSquelchOverride(boolean override); +} diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java new file mode 100644 index 00000000..57423454 --- /dev/null +++ b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelch.java @@ -0,0 +1,529 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.dsp.squelch; + +import 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.window.WindowType; +import io.github.dsheirer.sample.Listener; +import java.util.Arrays; + +/** + * Squelch processor for unfiltered, demodulated audio that operators by high-pass filtering the audio to remove below + * 3,000 Hertz and processes 10-millisecond audio buffers to calculate the noise variance across the samples. This + * variance is compared to a noise threshold and uses time hysteresis in the form of consecutive buffers to toggle the + * squelch open or closed. Uses a delay buffer equal in length to the hysteresis plus two which enables locating the + * precise transition point of squelch/un-squelch and passing the audio of the detected signal while blocking the audio + * when there is no signal detected. Register an audio listener to receive squelch controlled audio and a squelch state + * listener to receive squelch toggle indications. + */ +public class NoiseSquelch implements INoiseSquelchController +{ + public static final int DEFAULT_HYSTERESIS_OPEN_THRESHOLD = 4; + public static final int DEFAULT_HYSTERESIS_CLOSE_THRESHOLD = 6; + public static final int MINIMUM_HYSTERESIS_THRESHOLD = 1; + public static final int MAXIMUM_HYSTERESIS_THRESHOLD = 10; + public static final int VARIANCE_CALCULATION_WINDOW_MILLISECONDS = 10; + public static final float DEFAULT_NOISE_OPEN_THRESHOLD = 0.1f; + 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 float[] mFilteredBuffer = new float[0]; + private float[] mAudioBuffer = new float[0]; + private float mMeanAccumulator; + private float mNoiseOpenThreshold = DEFAULT_NOISE_OPEN_THRESHOLD; + private float mNoiseCloseThreshold = DEFAULT_NOISE_CLOSE_THRESHOLD; + private boolean mSquelch = true; + private boolean mSquelchOverride = false; + private int mMeanAccumulatorPointer; + private int mVarianceWindowSize; + private int mHysteresisOpenThreshold = DEFAULT_HYSTERESIS_OPEN_THRESHOLD; + private int mHysteresisCloseThreshold = DEFAULT_HYSTERESIS_CLOSE_THRESHOLD; + private int mHysteresisCount = 0; + private int mSquelchStateBroadcastCounter = 0; + private int mSquelchOpenIndex = 0; + private int mAudioBufferFilterDelay; + private IRealFilter mHighPassFilter; + private Listener mAudioListener; + private Listener mSquelchStateListener; + private Listener mNoiseSquelchStateListener; + + /** + * Constructs an instance + * @param noiseOpen in range 0.0 to 1.0 - recommend 0.1 to 0.2 (units of demodulated, high-pass filtered audio + * sample variance where the high-pass filtered audio samples range -1.0 to 1.0 and maximum variance is half of that + * range: 1.0) + * @param hysteresisOpen in range 1 to 10 controls the squelch toggle hysteresis - recommend 4 (units of 10 ms) + */ + public NoiseSquelch(float noiseOpen, float noiseClose, int hysteresisOpen, int hysteresisClose) + { + setNoiseThreshold(noiseOpen, noiseClose); + setHysteresisThreshold(hysteresisOpen, hysteresisClose); + } + + /** + * Indicates if the current state is squelched. + * @return true if squelched and false if not squelched. + */ + public boolean isSquelched() + { + return mSquelch; + } + + /** + * Sets the manual squelch override. + * @param override (true) or (false) to turn off manual squelch override. + */ + @Override + public void setSquelchOverride(boolean override) + { + mSquelchOverride = override; + + if(mSquelchOverride) + { + broadcast(SquelchState.UNSQUELCH); + } + } + + /** + * Sets the noise thresholds + * @param open in range 0.0-1.0 and less than or equal to close, recommend 0.1 + * @param close in range 0.0-1.0 and greater than or equal to open, recommend 0.2 + */ + public void setNoiseThreshold(float open, float close) + { + if(open < MINIMUM_NOISE_THRESHOLD || close > MAXIMUM_NOISE_THRESHOLD || open > close) + { + throw new IllegalArgumentException("Noise thresholds open/close [" + open + "/" + close + "] - open must be less than or equal to close and both in range " + MINIMUM_NOISE_THRESHOLD + "-" + MAXIMUM_NOISE_THRESHOLD); + } + + mNoiseOpenThreshold = open; + mNoiseCloseThreshold = close; + } + + /** + * Updates the hysteresis thresholds + * @param open in range 1 to 10 and less than or equal to close, recommend 4. + * @param close in range 1 to 10 and greater than or equal to open, recommend 6. + */ + public void setHysteresisThreshold(int open, int close) + { + if(open < MINIMUM_HYSTERESIS_THRESHOLD || close > MAXIMUM_HYSTERESIS_THRESHOLD || open > close) + { + throw new IllegalArgumentException("Hysteresis threshold open [" + open + "] must be less than or equal to close [" + + close + "] and both in range " + MINIMUM_HYSTERESIS_THRESHOLD + "-" + MAXIMUM_HYSTERESIS_THRESHOLD); + } + + mHysteresisOpenThreshold = open; + mHysteresisCloseThreshold = close; + mHysteresisCount = Math.min(mHysteresisCloseThreshold, mHysteresisCount); + } + + /** + * Registers the listener to receive noise squelch state updates every 100 milliseconds. + * @param listener to register, or de-register by passing in a null. + */ + public void setNoiseSquelchStateListener(Listener listener) + { + mNoiseSquelchStateListener = listener; + } + + /** + * Registers the listener to receive squelch state indications. Squelch state events will be sent to the listener + * in concert with processed audio buffers. An un-squelch event is followed by one or more audio buffers and then a + * squelch event. + * @param listener to register or de-register by passing in a null. + */ + public void setSquelchStateListener(Listener listener) + { + mSquelchStateListener = listener; + } + + /** + * Registers the listener to receive processed audio buffers in between un-squelch/squelch events. + * @param listener to register or de-register by passing in a null. + */ + public void setAudioListener(Listener listener) + { + mAudioListener = listener; + } + + /** + * Broadcasts the current noise squelch state to an optionally registered listener. This can be use used in the + * user interface to display the state of the channel and noise squelch activity. + * @param noise variance from the current 10-millisecond audio buffer. + */ + private void broadcastNoiseSquelchState(float noise) + { + if(mNoiseSquelchStateListener != null) + { + mNoiseSquelchStateListener.receive(new NoiseSquelchState(mSquelch, mSquelchOverride, noise, + mNoiseOpenThreshold, mNoiseCloseThreshold, mHysteresisCount, mHysteresisOpenThreshold, + mHysteresisCloseThreshold)); + } + } + + /** + * Broadcasts from the unfiltered audio buffer to an optionally registered audio listener. Note: the start and end + * indices are pointers to the filtered audio buffer, and we dispatch from the unfiltered audio buffer which is offset + * by half the length of the high-pass audio filter coefficients. This method adjusts the index arguments by this + * offset value to align the unfiltered audio with the high-pass filtered audio detection boundaries. + * + * @param start index of the filtered audio buffer + * @param end index of the filtered audio buffer + */ + private void broadcast(int start, int end) + { + if(mAudioListener != null) + { + mAudioListener.receive(Arrays.copyOfRange(mAudioBuffer, start, end)); + } + } + + /** + * Broadcasts the squelch state change to an optionally registered squelch state listener. + * @param squelchState to dispatch. + */ + private void broadcast(SquelchState squelchState) + { + //Override squelch state when we're in manual override + if(squelchState == SquelchState.SQUELCH && mSquelchOverride) + { + squelchState = SquelchState.UNSQUELCH; + } + + if(mSquelchStateListener != null) + { + mSquelchStateListener.receive(squelchState); + } + } + + /** + * Sets the sample rate for the incoming demodulated audio sample stream. + * @param sampleRate of the sample stream. + */ + public void setSampleRate(double sampleRate) + { + float[] coefficients = FilterFactory.getHighPass((int)sampleRate, 3000, 31, WindowType.BLACKMAN_HARRIS_7); + mHighPassFilter = new RealFIRFilter(coefficients); + mVarianceWindowSize = (int)(sampleRate * (VARIANCE_CALCULATION_WINDOW_MILLISECONDS / 1000.0)); + + //Offset for the filter delay between the audio buffer and the filtered audio noise detection buffer. + mAudioBufferFilterDelay = (int)Math.ceil(coefficients.length / 2.0); + } + + public void process(float[] samples) + { + float[] filtered = mHighPassFilter.filter(samples); + + int requiredLength = filtered.length + (mVarianceWindowSize * (mHysteresisCloseThreshold + 2)); + + //Resize the delay buffers if the incoming sample buffer size changes. + if(mFilteredBuffer.length != requiredLength) + { + mFilteredBuffer = Arrays.copyOf(mFilteredBuffer, requiredLength); + + //The audio buffer is longer to account for filter delay of the high pass filter and to align the indices + //across the filtered and audio buffers. + mAudioBuffer = Arrays.copyOf(mAudioBuffer, requiredLength + mAudioBufferFilterDelay); + } + + //Shuffle the samples to the left in the delay buffers and copy in the new samples. + int length = mFilteredBuffer.length - filtered.length; + System.arraycopy(mFilteredBuffer, filtered.length, mFilteredBuffer, 0, length); + System.arraycopy(filtered, 0, mFilteredBuffer, length, filtered.length); + + length = mAudioBuffer.length - samples.length; + System.arraycopy(mAudioBuffer, samples.length, mAudioBuffer, 0, length); + System.arraycopy(samples, 0, mAudioBuffer, length, samples.length); + + int squelchCloseIndex; + //Adjust the squelch open index in sync with the left-shuffle of the audio samples. + + mSquelchOpenIndex = Math.max(mSquelchOpenIndex - samples.length, 0); + + //Process the newly added (filtered) samples in the delay buffer. + for(int x = mFilteredBuffer.length - filtered.length; x < mFilteredBuffer.length; x++) + { + mMeanAccumulator += mFilteredBuffer[x]; + mMeanAccumulatorPointer++; + + if(mMeanAccumulatorPointer == mVarianceWindowSize) + { + float mean = mMeanAccumulator / mVarianceWindowSize; + + //Reset averaging variables + mMeanAccumulator = 0.0f; + mMeanAccumulatorPointer = 0; + + //Calculate the variance + float varianceAccumulator1 = 0.0f, deviation, varianceAccumulator2 = 0.0f; + for(int i = x - mVarianceWindowSize + 1; i <= x; i++) + { + deviation = mFilteredBuffer[i] - mean; + varianceAccumulator1 += deviation * deviation; + varianceAccumulator2 += deviation; + } + + //Formula from Apache Commons Math - Variance class. + float noiseVariance = (varianceAccumulator1 - (varianceAccumulator2 * varianceAccumulator2 / mVarianceWindowSize)) / mVarianceWindowSize; + boolean below = mSquelch ? (noiseVariance < mNoiseOpenThreshold) : (noiseVariance < mNoiseCloseThreshold); + mHysteresisCount += (below ? 1 : -1); + + if(mSquelch && mHysteresisCount >= mHysteresisOpenThreshold) + { + mSquelch = false; + mHysteresisCount = mHysteresisOpenThreshold; + mSquelchOpenIndex = findTransition(x); + + if(!mSquelchOverride) + { + broadcast(SquelchState.UNSQUELCH); + } + } + else if(!mSquelch && mHysteresisCount <= 0) + { + mSquelch = true; + mHysteresisCount = 0; + squelchCloseIndex = findTransition(x); + + //Broadcast the partial audio segment ending at the transition point. + if(mSquelchOpenIndex < squelchCloseIndex) + { + //Only broadcast the truncated audio samples when we're not in squelch override + if(!mSquelchOverride) + { + broadcast(mSquelchOpenIndex, squelchCloseIndex); + } + + broadcast(SquelchState.SQUELCH); + } + + mSquelchOpenIndex = 0; + } + else if(mSquelch && !mSquelchOverride) + { + int start = x - (mVarianceWindowSize * mHysteresisOpenThreshold) + 1; + int end = start + mVarianceWindowSize; + //Make sure start doesn't go negative + start = Math.max(0, start); + Arrays.fill(mAudioBuffer, start, end, 0.0f); + } + + mHysteresisCount = Math.min(mHysteresisCount, mHysteresisCloseThreshold); + mHysteresisCount = Math.max(mHysteresisCount, 0); + + //Broadcast squelch state to an optionally registered listener (the channel tab in user interface) + mSquelchStateBroadcastCounter++; + if(mSquelchStateBroadcastCounter >= 5) + { + broadcastNoiseSquelchState(noiseVariance); + mSquelchStateBroadcastCounter -= 5; + } + } + } + + if(mSquelchOverride) + { + broadcast(0, samples.length); + } + else if(!mSquelch && mSquelchOpenIndex < (samples.length)) + { + mSquelchOpenIndex = Math.max(mSquelchOpenIndex, 0); + //Dispatch audio after processing the audio buffer if we end in an un-squelch state. + broadcast(mSquelchOpenIndex, samples.length); + mSquelchOpenIndex = 0; + } + } + + /** + * Identifies the squelch transition point across a hysteresis window delayed by 2x buffer periods from the current + * processing index. This enables fine-grained squelch control without losing audio during the un-squelch period or + * allowing noise to leak into the squelch period at the end of the transmission. Uses a reduced 20-sample variance + * calculation window extended to the hysteresis time period to detect the transition point. Identifies the + * transition point within +/- 20 audio samples. + * + * @param current buffer processing index. + * @return buffer index of detected transition. + */ + private int findTransition(int current) + { + //Search window is 10-ms buffer size extended to hysteresis period, delayed by 2x buffer lengths. + int window = mSquelch ? mHysteresisCloseThreshold : mHysteresisOpenThreshold; + + //Thresholds here may seem reversed but keep in mind that the mSquelch state has already been flipped and thus + // represents the state moving forward, and we need to detect the transition from the previous mSquelch state. + float varianceThreshold = mSquelch ? mNoiseCloseThreshold : mNoiseOpenThreshold; + int hysteresisThreshold = mSquelch ? mHysteresisCloseThreshold : mHysteresisOpenThreshold; + + //Look back window start and end indices are sized according the previous mSquelch state + int start = current - (mVarianceWindowSize * (window + 2)); + int end = start + (mVarianceWindowSize * window); + + int hysteresisCount = 0; + int windowSize = mVarianceWindowSize / 5; //Decrease transition variance window to 1/5th of the normal variance window + int transitionPointer = 0; + + for(int x = start; x < end; x += windowSize) + { + float mean = getMean(x, x + windowSize); + float variance = getVariance(x, x + windowSize, mean); + + //Detect transition from from un-squelch to squelch + boolean exceedsThreshold = (mSquelch ? (variance > varianceThreshold) : (variance < varianceThreshold)); + + if(exceedsThreshold) + { + if(hysteresisCount == 0) + { + hysteresisCount++; + transitionPointer = x; + } + else + { + hysteresisCount++; + + if(hysteresisCount >= hysteresisThreshold) + { + return transitionPointer; + } + } + } + else + { + hysteresisCount--; + hysteresisCount = Math.max(0, hysteresisCount); + } + } + + return 0; + } + + /** + * Calculate the mean/average of values between the start and end indices. + * @param start index + * @param end index + * @return mean value. + */ + private float getMean(int start, int end) + { + if(start < 0) + { + start = 0; + } + + if(end > mFilteredBuffer.length) + { + end = mFilteredBuffer.length; + } + + float accumulator = 0.0f; + + for(int x = start; x < end; x++) + { + accumulator += mFilteredBuffer[x]; + } + + return accumulator / (end - start); + } + + /** + * Calculates the variance of the values between start and end indices using the supplied mean value. + * @param start index + * @param end index + * @param mean of values between start and end, calculated previously + * @return variance. + */ + private float getVariance(int start, int end, float mean) + { + if(start < 0) + { + start = 0; + } + + if(end > mFilteredBuffer.length) + { + end = mFilteredBuffer.length; + } + + float varianceAccumulator1 = 0.0f, deviation, varianceAccumulator2 = 0.0f; + + for(int i = start; i < end; i++) + { + deviation = mFilteredBuffer[i] - mean; + varianceAccumulator1 += deviation * deviation; + varianceAccumulator2 += deviation; + } + + int sampleSize = end - start; + + //Formula from Apache Commons Math - Variance class. + return (varianceAccumulator1 - (varianceAccumulator2 * varianceAccumulator2 / sampleSize)) / sampleSize; + } + + public static void main(String[] args) + { +// Path directory = Paths.get("/media/denny/T9/NBFM Squelch Research"); //Linux +// Path file = directory.resolve("20250629_072704_453850000_Onondaga-County_Fire-EMS_OC-Fire-Disp_79_baseband.wav"); +// Path file = directory.resolve("DMR_1_CAPPLUS.wav"); +// Path file = directory.resolve("20250702_034109_145510000_R828D_V4_POLY_No_Antenna_Normal_Gain.wav"); +// Path file = directory.resolve("20250702_034301_145510000_R828D_V4_POLY_No_Antenna_Max_Gain.wav"); +// Path file = directory.resolve("20250702_034403_145510000_R828D_V4_POLY_No_Antenna_Min_Gain.wav"); +// Path file = directory.resolve("20250702_034541_145510000_R828D_V4_HET_No_Antenna_Min_Gain.wav"); +// Path file = directory.resolve("20250702_034635_145510000_R828D_V4_HET_No_Antenna_Max_Gain.wav"); +// Path file = directory.resolve("20250702_034742_145510000_R828D_V4_HET_No_Antenna_Normal_Gain.wav"); +// Path file = directory.resolve("20250703_043912_162500000_NOAA_25_khz_no_center_signal.wav"); +// Path file = directory.resolve("20250703_043958_162550000_NOAA_25_khz_yes_center_signal.wav"); +// +// DecodeConfigNBFM config = new DecodeConfigNBFM(); +// config.setBandwidth(DecodeConfigAnalog.Bandwidth.BW_12_5); +// NBFMDecoder decoder = new NBFMDecoder(config); +// +// try(ComplexWaveSource source = new ComplexWaveSource(file.toFile(), false)) +// { +// source.setListener(iNativeBuffer -> { +// Iterator it = iNativeBuffer.iterator(); +// +// while(it.hasNext()) +// { +// ComplexSamples samples = it.next(); +// decoder.receive(samples); +// } +// }); +// source.start(); +// +// decoder.getSourceEventListener().receive(SourceEvent.sampleRateChange(source.getSampleRate())); +// +// while(true) +// { +// source.next(2048, true); +// } +// } +// catch(IOException ioe) +// { +// +// ioe.printStackTrace(); +// } +// +// System.out.println("Finished"); + } +} diff --git a/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelchState.java b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelchState.java new file mode 100644 index 00000000..582981e4 --- /dev/null +++ b/src/main/java/io/github/dsheirer/dsp/squelch/NoiseSquelchState.java @@ -0,0 +1,35 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.dsp.squelch; + +/** + * Noise Squelch operating status record. Captures a snapshot in time of the squelch status. + * @param squelch state, on (true) or off (false) + * @param squelchOverride on (true) or off (false) + * @param noise - current noise variance + * @param noiseOpenThreshold for determining un-squelch state + * @param noiseCloseThreshold for determining squelch state + * @param hysteresis in units of 10-milliseconds + * @param hysteresisOpenThreshold for toggling the squelch state. + * @param hysteresisCloseThreshold for toggling the squelch state. + */ +public record NoiseSquelchState(boolean squelch, boolean squelchOverride, float noise, float noiseOpenThreshold, + float noiseCloseThreshold, int hysteresis, int hysteresisOpenThreshold, + int hysteresisCloseThreshold){} diff --git a/src/main/java/io/github/dsheirer/gui/power/ChannelPowerPanel.java b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java similarity index 66% rename from src/main/java/io/github/dsheirer/gui/power/ChannelPowerPanel.java rename to src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java index 44e473b1..b8a23e49 100644 --- a/src/main/java/io/github/dsheirer/gui/power/ChannelPowerPanel.java +++ b/src/main/java/io/github/dsheirer/gui/channel/ChannelSpectrumPanel.java @@ -17,13 +17,16 @@ * **************************************************************************** */ -package io.github.dsheirer.gui.power; +package io.github.dsheirer.gui.channel; import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.dsp.filter.channelizer.PolyphaseChannelSource; -import io.github.dsheirer.dsp.squelch.ISquelchConfiguration; -import io.github.dsheirer.gui.control.DbPowerMeter; +import io.github.dsheirer.gui.power.SignalPowerView; +import io.github.dsheirer.gui.squelch.NoiseSquelchView; import io.github.dsheirer.module.ProcessingChain; +import io.github.dsheirer.module.decode.PrimaryDecoder; +import io.github.dsheirer.module.decode.am.AMDecoder; +import io.github.dsheirer.module.decode.nbfm.NBFMDecoder; import io.github.dsheirer.playlist.PlaylistManager; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.sample.complex.ComplexSamplesToNativeBufferModule; @@ -46,115 +49,55 @@ import java.awt.event.MouseEvent; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.List; -import jiconfont.icons.font_awesome.FontAwesome; -import jiconfont.swing.IconFontSwing; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; import net.miginfocom.swing.MigLayout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.swing.Icon; import javax.swing.JButton; -import javax.swing.JCheckBox; import javax.swing.JLabel; import javax.swing.JLayeredPane; import javax.swing.JPanel; -import javax.swing.JSeparator; import javax.swing.JSpinner; +import javax.swing.JSplitPane; import javax.swing.SpinnerNumberModel; import javax.swing.event.MouseInputAdapter; /** - * Display for channel power and squelch details + * Display for channel FFT and squelch details */ -public class ChannelPowerPanel extends JPanel implements Listener +public class ChannelSpectrumPanel extends JPanel implements Listener { - private static final Logger mLog = LoggerFactory.getLogger(ChannelPowerPanel.class); - private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0"); + private static final Logger mLog = LoggerFactory.getLogger(ChannelSpectrumPanel.class); private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000"); - private static final String NOT_AVAILABLE = "Not Available"; private final PlaylistManager mPlaylistManager; private ProcessingChain mProcessingChain; private final ComplexSamplesToNativeBufferModule mSampleStreamTapModule = new ComplexSamplesToNativeBufferModule(); private final ComplexDftProcessor mComplexDftProcessor = new ComplexDftProcessor(); private SpectrumPanel mSpectrumPanel; private final FrequencyOverlayPanel mFrequencyOverlayPanel; - private final DbPowerMeter mPowerMeter = new DbPowerMeter(); - private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER); private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor(); private final SpinnerNumberModel mNoiseFloorSpinnerModel; - private final JLabel mPowerLabel; - private final JLabel mPeakLabel; - private final JLabel mSquelchLabel; - private final JLabel mSquelchValueLabel; private final JLabel mEstimatedCarrierOffsetFrequencyLabel; private final JLabel mEstimatedCarrierOffsetFrequencyValueLabel; - private final JButton mSquelchUpButton; - private final JButton mSquelchDownButton; - private final JCheckBox mSquelchAutoTrackCheckBox; - private double mSquelchThreshold; private boolean mPanelVisible = false; private boolean mDftProcessing = false; + private final NoiseSquelchView mNoiseSquelchView; + private final SignalPowerView mSignalPowerView; + private final JFXPanel mNoiseSquelchPanel; + private JSplitPane mSplitPane; /** * Constructs an instance. */ - public ChannelPowerPanel(PlaylistManager playlistManager, SettingsManager settingsManager) + public ChannelSpectrumPanel(PlaylistManager playlistManager, SettingsManager settingsManager) { mPlaylistManager = playlistManager; - - setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]")); - mPowerMeter.setPeakVisible(true); - mPowerMeter.setSquelchThresholdVisible(true); - add(mPowerMeter); - - JPanel valuePanel = new JPanel(); - valuePanel.setLayout(new MigLayout("", "[right][left][][]", "")); - - mPeakLabel = new JLabel("0"); - mPeakLabel.setToolTipText("Current peak power level in decibels."); - valuePanel.add(new JLabel("Peak:")); - valuePanel.add(mPeakLabel, "wrap"); - - mPowerLabel = new JLabel("0"); - mPowerLabel.setToolTipText("Current Power level in decibels"); - valuePanel.add(new JLabel("Power:")); - valuePanel.add(mPowerLabel, "wrap"); - - mSquelchLabel = new JLabel("Squelch:"); - mSquelchLabel.setEnabled(false); - valuePanel.add(mSquelchLabel); - mSquelchValueLabel = new JLabel(NOT_AVAILABLE); - mSquelchValueLabel.setToolTipText("Squelch threshold value in decibels"); - mSquelchValueLabel.setEnabled(false); - valuePanel.add(mSquelchValueLabel, "wrap"); - - IconFontSwing.register(FontAwesome.getIconFont()); - Icon iconUp = IconFontSwing.buildIcon(FontAwesome.ANGLE_UP, 12); - mSquelchUpButton = new JButton(iconUp); - mSquelchUpButton.setToolTipText("Increases the squelch threshold value"); - mSquelchUpButton.setEnabled(false); - mSquelchUpButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1))); - valuePanel.add(mSquelchUpButton); - - Icon iconDown = IconFontSwing.buildIcon(FontAwesome.ANGLE_DOWN, 12); - mSquelchDownButton = new JButton(iconDown); - mSquelchDownButton.setToolTipText("Decreases the squelch threshold value."); - mSquelchDownButton.setEnabled(false); - mSquelchDownButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1))); - valuePanel.add(mSquelchDownButton, "wrap"); - - mSquelchAutoTrackCheckBox = new JCheckBox("Auto Track"); - mSquelchAutoTrackCheckBox.setToolTipText("Enable or disable monitoring of the noise floor to auto-adjust the " + - "squelch threshold value maintaining a consistent level/buffer above the noise floor"); - mSquelchAutoTrackCheckBox.setEnabled(false); - mSquelchAutoTrackCheckBox.addActionListener(e -> - { - broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected())); - }); - valuePanel.add(mSquelchAutoTrackCheckBox, "span,left"); - - add(valuePanel); - add(new JSeparator(JSeparator.VERTICAL)); + mNoiseSquelchView = new NoiseSquelchView(mPlaylistManager); + mSignalPowerView = new SignalPowerView(mPlaylistManager); + setLayout(new MigLayout("insets 0", "[grow,fill]", "[grow,fill]")); JPanel fftPanel = new JPanel(); fftPanel.setLayout(new MigLayout("insets 0", "[grow,fill]", "[][grow,fill]")); @@ -163,7 +106,7 @@ public class ChannelPowerPanel extends JPanel implements Listener { + Scene scene = new Scene(mNoiseSquelchView); + mNoiseSquelchPanel.setScene(scene); + }); + + mSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + mSplitPane.add(fftPanel, JSplitPane.LEFT); + mSplitPane.add(mNoiseSquelchPanel, JSplitPane.RIGHT); + mSplitPane.setDividerLocation(0.5); + add(mSplitPane); mSampleStreamTapModule.setListener(mComplexDftProcessor); DFTResultsConverter DFTResultsConverter = new ComplexDecibelConverter(); @@ -272,6 +228,7 @@ public class ChannelPowerPanel extends JPanel implements Listener - { - final double power = sourceEvent.getValue().doubleValue(); - final double peak = mPeakMonitor.process(power); - - EventQueue.invokeLater(() -> { - mPowerMeter.setPower(power); - mPowerLabel.setText(DECIMAL_FORMAT.format(power)); - - mPowerMeter.setPeak(peak); - mPeakLabel.setText(DECIMAL_FORMAT.format(peak)); - }); - } - case NOTIFICATION_SQUELCH_THRESHOLD -> - { - final double threshold = sourceEvent.getValue().doubleValue(); - mSquelchThreshold = threshold; - setConfigSquelchThreshold((int)threshold); - - EventQueue.invokeLater(() -> { - mPowerMeter.setSquelchThreshold(threshold); - mSquelchLabel.setEnabled(true); - mSquelchValueLabel.setEnabled(true); - mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold)); - mSquelchDownButton.setEnabled(true); - mSquelchUpButton.setEnabled(true); - }); - } - case NOTIFICATION_CARRIER_OFFSET_FREQUENCY -> - { - updateEstimatedCarrierOffsetFrequency(sourceEvent.getValue().longValue()); - } - case NOTIFICATION_SQUELCH_AUTO_TRACK -> - { - boolean autoTrack = sourceEvent.getValue().intValue() == 1; - setConfigSquelchAutoTrack(autoTrack); - EventQueue.invokeLater(() -> { - mSquelchAutoTrackCheckBox.setSelected(autoTrack); - mSquelchAutoTrackCheckBox.setEnabled(true); - }); - } + updateEstimatedCarrierOffsetFrequency(sourceEvent.getValue().longValue()); } + + mSignalPowerView.receive(sourceEvent); } } diff --git a/src/main/java/io/github/dsheirer/gui/playlist/channel/NBFMConfigurationEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/channel/NBFMConfigurationEditor.java index 8bb156e3..4149ac85 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/channel/NBFMConfigurationEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/channel/NBFMConfigurationEditor.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -19,7 +19,6 @@ package io.github.dsheirer.gui.playlist.channel; -import io.github.dsheirer.gui.control.DbPowerMeter; import io.github.dsheirer.gui.control.HexFormatter; import io.github.dsheirer.gui.control.IntegerFormatter; import io.github.dsheirer.gui.playlist.decoder.AuxDecoderConfigurationEditor; @@ -70,12 +69,8 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor private TitledPane mRecordPane; private TitledPane mSourcePane; private TextField mTalkgroupField; - private TextField mSquelchThresholdField; - private ToggleSwitch mSquelchAutoTrackSwitch; private ToggleSwitch mAudioFilterEnable; private TextFormatter mTalkgroupTextFormatter; - private final IntegerFormatter mSquelchTextFormatter = new IntegerFormatter((int)DbPowerMeter.DEFAULT_MINIMUM_POWER, - (int)DbPowerMeter.DEFAULT_MAXIMUM_POWER); private ToggleSwitch mBasebandRecordSwitch; private SegmentedButton mBandwidthButton; @@ -141,23 +136,6 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor GridPane.setConstraints(getBandwidthButton(), 1, 0); gridPane.getChildren().add(getBandwidthButton()); - Label squelchLabel = new Label("Squelch Threshold"); - GridPane.setHalignment(squelchLabel, HPos.RIGHT); - GridPane.setConstraints(squelchLabel, 2, 0); - gridPane.getChildren().add(squelchLabel); - - GridPane.setConstraints(getSquelchThresholdField(), 3, 0); - gridPane.getChildren().add(getSquelchThresholdField()); - - Label autoTrackLabel = new Label("Squelch Auto-Track"); - GridPane.setHalignment(autoTrackLabel, HPos.RIGHT); - GridPane.setConstraints(autoTrackLabel, 4, 0); - gridPane.getChildren().add(autoTrackLabel); - - GridPane.setConstraints(getSquelchAutoTrackSwitch(), 5, 0); - GridPane.setHalignment(getSquelchAutoTrackSwitch(), HPos.LEFT); - gridPane.getChildren().add(getSquelchAutoTrackSwitch()); - Label talkgroupLabel = new Label("Talkgroup To Assign"); GridPane.setHalignment(talkgroupLabel, HPos.RIGHT); GridPane.setConstraints(talkgroupLabel, 0, 1); @@ -354,32 +332,6 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor return mTalkgroupField; } - private TextField getSquelchThresholdField() - { - if(mSquelchThresholdField == null) - { - mSquelchThresholdField = new TextField(); - mSquelchThresholdField.setTextFormatter(mSquelchTextFormatter); - mSquelchTextFormatter.valueProperty().addListener((observable, oldValue, newValue) -> modifiedProperty().set(true)); - } - - return mSquelchThresholdField; - } - - /** - * Squelch noise floor auto-track feature. - */ - private ToggleSwitch getSquelchAutoTrackSwitch() - { - if(mSquelchAutoTrackSwitch == null) - { - mSquelchAutoTrackSwitch = new ToggleSwitch(); - mSquelchAutoTrackSwitch.selectedProperty().addListener((observable, oldValue, newValue) -> modifiedProperty().set(true)); - } - - return mSquelchAutoTrackSwitch; - } - /** * Updates the talkgroup editor's text formatter. * @param value to set in the control. @@ -458,10 +410,6 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor } updateTextFormatter(decodeConfigNBFM.getTalkgroup()); - getSquelchThresholdField().setDisable(false); - mSquelchTextFormatter.setValue(decodeConfigNBFM.getSquelchThreshold()); - getSquelchAutoTrackSwitch().setDisable(false); - getSquelchAutoTrackSwitch().setSelected(decodeConfigNBFM.isSquelchAutoTrack()); getAudioFilterEnable().setDisable(false); getAudioFilterEnable().setSelected(decodeConfigNBFM.isAudioFilter()); } @@ -476,9 +424,6 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor updateTextFormatter(0); getTalkgroupField().setDisable(true); - getSquelchThresholdField().setDisable(true); - getSquelchAutoTrackSwitch().setDisable(true); - getSquelchAutoTrackSwitch().setSelected(false); getAudioFilterEnable().setDisable(true); getAudioFilterEnable().setSelected(false); } @@ -515,8 +460,6 @@ public class NBFMConfigurationEditor extends ChannelConfigurationEditor } config.setTalkgroup(talkgroup); - config.setSquelchThreshold(mSquelchTextFormatter.getValue()); - config.setSquelchAutoTrack(getSquelchAutoTrackSwitch().isSelected()); config.setAudioFilter(getAudioFilterEnable().isSelected()); getItem().setDecodeConfiguration(config); } diff --git a/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java new file mode 100644 index 00000000..442a2768 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/power/SignalPowerView.java @@ -0,0 +1,229 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.power; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.dsp.squelch.ISquelchConfiguration; +import io.github.dsheirer.gui.control.DbPowerMeter; +import io.github.dsheirer.module.ProcessingChain; +import io.github.dsheirer.playlist.PlaylistManager; +import io.github.dsheirer.source.SourceEvent; +import java.awt.EventQueue; +import java.text.DecimalFormat; +import jiconfont.icons.font_awesome.FontAwesome; +import jiconfont.swing.IconFontSwing; +import net.miginfocom.swing.MigLayout; + +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; + +/** + * Swing view for displaying signal power measurements with integrated squelch control. + */ +public class SignalPowerView extends JPanel +{ + private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.0"); + private static final String NOT_AVAILABLE = "Not Available"; + private final DbPowerMeter mPowerMeter = new DbPowerMeter(); + private final PeakMonitor mPeakMonitor = new PeakMonitor(DbPowerMeter.DEFAULT_MINIMUM_POWER); + private final JLabel mPowerLabel; + private final JLabel mPeakLabel; + private final JLabel mSquelchLabel; + private final JLabel mSquelchValueLabel; + private final JButton mSquelchUpButton; + private final JButton mSquelchDownButton; + private final JCheckBox mSquelchAutoTrackCheckBox; + private double mSquelchThreshold; + private final PlaylistManager mPlaylistManager; + private ProcessingChain mProcessingChain; + + public SignalPowerView(PlaylistManager playlistManager) + { + mPlaylistManager = playlistManager; + + setLayout(new MigLayout("", "[][][][grow,fill]", "[grow,fill]")); + mPowerMeter.setPeakVisible(true); + mPowerMeter.setSquelchThresholdVisible(true); + add(mPowerMeter); + + JPanel valuePanel = new JPanel(); + valuePanel.setLayout(new MigLayout("", "[right][left][][]", "")); + + mPeakLabel = new JLabel("0"); + mPeakLabel.setToolTipText("Current peak power level in decibels."); + valuePanel.add(new JLabel("Peak:")); + valuePanel.add(mPeakLabel, "wrap"); + + mPowerLabel = new JLabel("0"); + mPowerLabel.setToolTipText("Current Power level in decibels"); + valuePanel.add(new JLabel("Power:")); + valuePanel.add(mPowerLabel, "wrap"); + + mSquelchLabel = new JLabel("Squelch:"); + mSquelchLabel.setEnabled(false); + valuePanel.add(mSquelchLabel); + mSquelchValueLabel = new JLabel(NOT_AVAILABLE); + mSquelchValueLabel.setToolTipText("Squelch threshold value in decibels"); + mSquelchValueLabel.setEnabled(false); + valuePanel.add(mSquelchValueLabel, "wrap"); + + IconFontSwing.register(FontAwesome.getIconFont()); + Icon iconUp = IconFontSwing.buildIcon(FontAwesome.ANGLE_UP, 12); + mSquelchUpButton = new JButton(iconUp); + mSquelchUpButton.setToolTipText("Increases the squelch threshold value"); + mSquelchUpButton.setEnabled(false); + mSquelchUpButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold + 1))); + valuePanel.add(mSquelchUpButton); + + Icon iconDown = IconFontSwing.buildIcon(FontAwesome.ANGLE_DOWN, 12); + mSquelchDownButton = new JButton(iconDown); + mSquelchDownButton.setToolTipText("Decreases the squelch threshold value."); + mSquelchDownButton.setEnabled(false); + mSquelchDownButton.addActionListener(e -> broadcast(SourceEvent.requestSquelchThreshold(null, mSquelchThreshold - 1))); + valuePanel.add(mSquelchDownButton, "wrap"); + + mSquelchAutoTrackCheckBox = new JCheckBox("Auto Track"); + mSquelchAutoTrackCheckBox.setToolTipText("Enable or disable monitoring of the noise floor to auto-adjust the " + + "squelch threshold value maintaining a consistent level/buffer above the noise floor"); + mSquelchAutoTrackCheckBox.setEnabled(false); + mSquelchAutoTrackCheckBox.addActionListener(e -> + { + broadcast(SourceEvent.requestSquelchAutoTrack(mSquelchAutoTrackCheckBox.isSelected())); + }); + valuePanel.add(mSquelchAutoTrackCheckBox, "span,left"); + + add(valuePanel); + } + + /** + * Updates the channel's decode configuration with a new squelch threshold value + */ + private void setConfigSquelchThreshold(int threshold) + { + if(mProcessingChain != null) + { + Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain); + + if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration) + { + configuration.setSquelchThreshold(threshold); + mPlaylistManager.schedulePlaylistSave(); + } + } + } + + /** + * Updates the channel configuration squelch auto-track feature setting. + * @param autoTrack true to enable. + */ + private void setConfigSquelchAutoTrack(boolean autoTrack) + { + Channel channel = mPlaylistManager.getChannelProcessingManager().getChannel(mProcessingChain); + + if(channel != null && channel.getDecodeConfiguration() instanceof ISquelchConfiguration configuration) + { + configuration.setSquelchAutoTrack(autoTrack); + mPlaylistManager.schedulePlaylistSave(); + } + } + + private void broadcast(SourceEvent sourceEvent) + { + if(mProcessingChain != null) + { + mProcessingChain.broadcast(sourceEvent); + } + } + + /** + * Resets controls when changing processing chain source. Note: this must be called on the Swing + * dispatch thread because it directly invokes swing components. + */ + private void reset() + { + mPeakMonitor.reset(); + mPowerMeter.reset(); + mPeakLabel.setText("0"); + mPowerLabel.setText("0"); + mSquelchLabel.setEnabled(false); + mSquelchValueLabel.setText("Not Available"); + mSquelchValueLabel.setEnabled(false); + mSquelchUpButton.setEnabled(false); + mSquelchDownButton.setEnabled(false); + mSquelchAutoTrackCheckBox.setEnabled(false); + mSquelchAutoTrackCheckBox.setSelected(false); + } + + public void receive(SourceEvent sourceEvent) + { + switch(sourceEvent.getEvent()) + { + case NOTIFICATION_CHANNEL_POWER -> + { + final double power = sourceEvent.getValue().doubleValue(); + final double peak = mPeakMonitor.process(power); + + EventQueue.invokeLater(() -> { + mPowerMeter.setPower(power); + mPowerLabel.setText(DECIMAL_FORMAT.format(power)); + + mPowerMeter.setPeak(peak); + mPeakLabel.setText(DECIMAL_FORMAT.format(peak)); + }); + } + case NOTIFICATION_SQUELCH_THRESHOLD -> + { + final double threshold = sourceEvent.getValue().doubleValue(); + mSquelchThreshold = threshold; + setConfigSquelchThreshold((int)threshold); + + EventQueue.invokeLater(() -> { + mPowerMeter.setSquelchThreshold(threshold); + mSquelchLabel.setEnabled(true); + mSquelchValueLabel.setEnabled(true); + mSquelchValueLabel.setText(DECIMAL_FORMAT.format(threshold)); + mSquelchDownButton.setEnabled(true); + mSquelchUpButton.setEnabled(true); + }); + } + case NOTIFICATION_SQUELCH_AUTO_TRACK -> + { + boolean autoTrack = sourceEvent.getValue().intValue() == 1; + setConfigSquelchAutoTrack(autoTrack); + EventQueue.invokeLater(() -> { + mSquelchAutoTrackCheckBox.setSelected(autoTrack); + mSquelchAutoTrackCheckBox.setEnabled(true); + }); + } + } + } + + /** + * Sets the processing chain for this view + */ + public void setProcessingChain(ProcessingChain processingChain) + { + mProcessingChain = processingChain; + reset(); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java b/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java index 273c957b..29b598b7 100644 --- a/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java +++ b/src/main/java/io/github/dsheirer/gui/preference/playback/PlaybackPreferenceEditor.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -305,7 +305,7 @@ public class PlaybackPreferenceEditor extends HBox } catch(Exception e) { - mLog.error("Error attempting to play audio test tone"); + mLog.error("Error attempting to play audio test tone", e); } } } diff --git a/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchSimulator.java b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchSimulator.java new file mode 100644 index 00000000..a4d4ad6a --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchSimulator.java @@ -0,0 +1,109 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.squelch; + +import io.github.dsheirer.dsp.squelch.INoiseSquelchController; +import io.github.dsheirer.dsp.squelch.NoiseSquelchState; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.util.ThreadPool; +import java.util.Random; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Simple simulator with integrated timer to produce random noise squelch state values to support debug and testing of + * noise squelch view. + */ +public class NoiseSquelchSimulator implements INoiseSquelchController +{ + private Listener mListener; + private float mNoiseOpenThreshold = 0.1f; + private float mNoiseCloseThreshold = 0.2f; + private int mHysteresisOpenThreshold = 4; + private int mHysteresisCloseThreshold = 6; + private int mHysteresis; + private int mHysteresisCounter; + private boolean mSquelchOverride; + private boolean mSquelch; + private ScheduledFuture mTimerFuture; + private Random mRandom = new Random(); + + @Override + public void setNoiseSquelchStateListener(Listener listener) + { + mListener = listener; + + if(mListener != null) + { + mTimerFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(this::dispatch, 0, 90, TimeUnit.MILLISECONDS); + } + else if(mTimerFuture != null) + { + mTimerFuture.cancel(false); + mTimerFuture = null; + } + } + + /** + * Sets the open and close noise thresholds in range 0.0 to 1.0, recommend: 0.1 open and 0.2 close + * @param open for the open noise variance calculation. + * @param close for the close noise variance calculation. + */ + @Override + public void setNoiseThreshold(float open, float close) + { + mNoiseOpenThreshold = open; + mNoiseCloseThreshold = close; + } + + @Override + public void setHysteresisThreshold(int open, int close) + { + if(mHysteresisOpenThreshold != open || mHysteresisCloseThreshold != close) + { + System.out.println("Setting hysteresis thresholds - open:" + open + " close:" + close); + mHysteresisOpenThreshold = open; + mHysteresisCloseThreshold = close; + } + } + + @Override + public void setSquelchOverride(boolean override) + { + mSquelchOverride = override; + } + + private void dispatch() + { + if(mListener != null) + { + mHysteresisCounter++; + if(mHysteresisCounter > 10) + { + mHysteresis = (int)(mRandom.nextFloat() * 10); + mHysteresis = Math.min(mHysteresis, mHysteresisCloseThreshold); + } + float noise = mRandom.nextFloat() * 0.5f; + mSquelch = noise > mNoiseOpenThreshold; + mListener.receive(new NoiseSquelchState(mSquelch, mSquelchOverride, noise, mNoiseOpenThreshold, + mNoiseCloseThreshold, mHysteresis, mHysteresisOpenThreshold, mHysteresisCloseThreshold)); + } + } +} diff --git a/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchTestApplication.java b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchTestApplication.java new file mode 100644 index 00000000..f68a471f --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchTestApplication.java @@ -0,0 +1,54 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.squelch; + +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.stage.Stage; +import jiconfont.icons.font_awesome.FontAwesome; +import jiconfont.javafx.IconFontFX; + +/** + * Test application for offline/standalone development and debug of the noise squelch and view. + */ +public class NoiseSquelchTestApplication extends Application +{ + public static void main(String[] args) + { + launch(args); + } + + @Override + public void start(Stage primaryStage) + { + //Register FontAwesome so we can use the fonts in Swing windows + IconFontFX.register(FontAwesome.getIconFont()); + + NoiseSquelchView view = new NoiseSquelchView(null); + view.setShowing(true); + Scene scene = new Scene(view, 700, 500); + primaryStage.setTitle("Noise Squelch View"); + primaryStage.setScene(scene); + primaryStage.show(); + + NoiseSquelchSimulator simulator = new NoiseSquelchSimulator(); + view.setController(simulator); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchView.java b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchView.java new file mode 100644 index 00000000..b00709ee --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/squelch/NoiseSquelchView.java @@ -0,0 +1,861 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2025 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.squelch; + +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.playlist.PlaylistManager; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.util.ThreadPool; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import javafx.application.Platform; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Side; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.NumberAxis; +import javafx.scene.chart.XYChart; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Separator; +import javafx.scene.control.Slider; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import jiconfont.icons.font_awesome.FontAwesome; +import jiconfont.javafx.IconNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JavaFX panel for graphical view of noise squelch operating state and control. + * + * The NoiseSquelch control can be configured with noise and hysteresis open and close thresholds. The two value ranges + * for noise and hysteresis run in opposite directions relative to squelch open and close. This view inverts the noise + * value range from (0.5 to 0.0) to (0.0 to 10.0) by inverting the noise and noise threshold values and scaling those + * values onto the range of 0-10 to present the user with a unified view of noise and hysteresis where both values can + * be plotted onto an x/y chart. + * + * The noise threshold sliders have a value range of 0.0 to 0.15. The open noise threshold slider inverts these values + * to a range of 0.15 to 0.0 and adds a constant of 0.10, boosting the range to 0.25 to 0.10. The close noise threshold + * slider also operates on a range of 0.0 to 0.15 and inverts the value to a range of 0.15 to 0.00 which is added to + * the open threshold for a noise squelch control range of (open + 0.15) to (open + 0.00). + * + * The hysteresis threshold sliders each use an operating range of 0-5. The open slider adds a minimum hysteresis of + * 1 for an operating range of 1-6. The close slider uses a range of 0-5 that is combined with the open slider for a + * range of (open + 0) to (open + 5). + */ +public class NoiseSquelchView extends VBox implements Listener +{ + private static final Logger LOGGER = LoggerFactory.getLogger(NoiseSquelchView.class); + private static final DecimalFormat DECIMAL_FORMAT = new DecimalFormat("0.00"); + + /** + * Scales the noise value (0.0 to 0.5) to align with the hysteresis value range (0.0 to 10.0). Value is determined + * by hysteresis-max (10.0) divided by noise-max (0.5). + */ + private static final float NOISE_DISPLAY_SCALOR = 20.0f; + + /** + * Noise inversion value for converting displayed values in range 0-10 to usable noise values in the range 10 - 0. + */ + private static final float NOISE_INVERSION_BASE = 10.0f; + + /** + * Noise inversion base to convert from the slider control range (0.0 to 0.15) to inverted range (0.15 to 0.00). + */ + private static final float NOISE_CONTROL_INVERSION_BASE = 0.15f; + + /** + * Minimum noise threshold constant. + */ + private static final float NOISE_CONTROL_OPEN_OFFSET = 0.10f; + + /** + * Noise squelch history buffer size for x-axis of the XY chart, in units of 10 milliseconds. + */ + private final int HISTORY_BUFFER_SIZE = 200; //200 x 10ms = 2,000ms / 2-second history view + + private final String NOT_AVAILABLE = "not available"; + private final PlaylistManager mPlaylistManager; + private final List mSquelchStateHistory = new ArrayList<>(); + private INoiseSquelchController mController; + private ScheduledFuture mTimerFuture; + + private ToggleButton mSquelchOverrideButton; + private Slider mOpenNoiseSlider; + private Slider mCloseNoiseSlider; + private Slider mOpenHysteresisSlider; + private Slider mCloseHysteresisSlider; + private int mOpenHysteresisShadow = 0; + private int mCloseHysteresisShadow = 0; + + private LineChart mActivityChart; + private final XYChart.Series mNoiseSeries = new XYChart.Series<>(); + private final XYChart.Series mNoiseOpenThresholdSeries = new XYChart.Series<>(); + private final XYChart.Series mNoiseCloseThresholdSeries = new XYChart.Series<>(); + private final XYChart.Series mHysteresisSeries = new XYChart.Series<>(); + private final XYChart.Series mHysteresisOpenThresholdSeries = new XYChart.Series<>(); + private final XYChart.Series mHysteresisCloseThresholdSeries = new XYChart.Series<>(); + private Label mSquelchStateLabel; + private Label mNoiseValueLabel; + private Label mHysteresisValueLabel; + private Button mResetButton; + + private boolean mShowing = false; + private boolean mControlsUpdated = true; + + /** + * Constructs an instance + * @param playlistManager - to trigger channel configuration changes when the user adjusts the noise squelch values. + */ + public NoiseSquelchView(PlaylistManager playlistManager) + { + mPlaylistManager = playlistManager; + init(); + } + + /** + * Setup the user interface components. + */ + private void init() + { + GridPane gridPane = new GridPane(); + gridPane.setHgap(2); + gridPane.setVgap(3); + gridPane.setPadding(new Insets(3)); + gridPane.setMaxWidth(Double.MAX_VALUE); + + //Header - Row 0 + int row = 0; + Label squelchHeaderLabel = new Label("Audio Squelch"); + GridPane.setHalignment(squelchHeaderLabel, HPos.CENTER); + gridPane.add(squelchHeaderLabel, 0, row); + + Separator verticalSeparate1 = new Separator(Orientation.VERTICAL); + verticalSeparate1.setPadding(new Insets(0, 2, 0, 2)); + gridPane.add(verticalSeparate1, 1, row, 1, 3); + + Label noiseHeaderLabel = new Label("Noise:"); + GridPane.setHalignment(noiseHeaderLabel, HPos.RIGHT); + gridPane.add(noiseHeaderLabel, 2, row); + + GridPane.setHgrow(getNoiseValueLabel(), Priority.NEVER); + GridPane.setHalignment(getNoiseValueLabel(), HPos.LEFT); + gridPane.add(getNoiseValueLabel(), 3, row); + + Label hysteresisHeaderLabel = new Label("Hysteresis:"); + GridPane.setHalignment(hysteresisHeaderLabel, HPos.RIGHT); + gridPane.add(hysteresisHeaderLabel, 4, row); + + GridPane.setHgrow(getHysteresisValueLabel(), Priority.NEVER); + GridPane.setHalignment(getHysteresisValueLabel(), HPos.LEFT); + gridPane.add(getHysteresisValueLabel(), 5, row); + + //Values - Row 1 + row++; + GridPane.setHalignment(getSquelchStateLabel(), HPos.CENTER); + gridPane.add(getSquelchStateLabel(), 0, row); + + Label noiseCloseRowLabel = new Label("Close:"); + GridPane.setHalignment(noiseCloseRowLabel, HPos.RIGHT); + gridPane.add(noiseCloseRowLabel, 2, row); + + GridPane.setHgrow(getCloseNoiseSlider(), Priority.ALWAYS); + gridPane.add(getCloseNoiseSlider(), 3, row); + + Label hysteresisOpenLabel = new Label("Open:"); + GridPane.setHalignment(hysteresisOpenLabel, HPos.RIGHT); + gridPane.add(hysteresisOpenLabel, 4, row); + + GridPane.setHgrow(getOpenHysteresisSlider(), Priority.ALWAYS); + gridPane.add(getOpenHysteresisSlider(), 5, row); + + //Values - Row 2 + row++; + + HBox buttonsBox = new HBox(); + buttonsBox.setMaxWidth(Double.MAX_VALUE); + buttonsBox.setSpacing(5); + HBox.setHgrow(getSquelchOverrideButton(), Priority.ALWAYS); + HBox.setHgrow(getResetButton(), Priority.ALWAYS); + buttonsBox.getChildren().addAll(getSquelchOverrideButton(), getResetButton()); + + GridPane.setHalignment(buttonsBox, HPos.LEFT); + GridPane.setHgrow(buttonsBox, Priority.SOMETIMES); + gridPane.add(buttonsBox, 0, row); + + Label noiseOpenRowLabel = new Label("Open:"); + GridPane.setHalignment(noiseOpenRowLabel, HPos.RIGHT); + gridPane.add(noiseOpenRowLabel, 2, row); + + GridPane.setHgrow(getOpenNoiseSlider(), Priority.ALWAYS); + gridPane.add(getOpenNoiseSlider(), 3, row); + + Label hysterisCloseRowLabel = new Label("Close:"); + GridPane.setHalignment(hysterisCloseRowLabel, HPos.RIGHT); + gridPane.add(hysterisCloseRowLabel, 4, row); + + GridPane.setHgrow(getCloseHysteresisSlider(), Priority.ALWAYS); + gridPane.add(getCloseHysteresisSlider(), 5, row); + + VBox.setVgrow(gridPane, Priority.NEVER); + VBox.setVgrow(getActivityChart(), Priority.ALWAYS); + getChildren().addAll(gridPane, getActivityChart()); + } + + /** + * Sets this view as showing and starts the chart update timer, or sets this view as hidden and stops the update timer + * @param showing to indicate if this view is selected by the user and showing. + */ + public void setShowing(boolean showing) + { + mShowing = showing; + updateTimer(); + } + + /** + * Updates the timer to process incoming decoder states and update the XY chart values. + */ + private synchronized void updateTimer() + { + if(mShowing && mController != null && mTimerFuture == null) + { + //Start the timer + mTimerFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(this::updateChart, 0, 50, TimeUnit.MILLISECONDS); + } + else if((!mShowing || mController == null) && mTimerFuture != null) + { + mTimerFuture.cancel(true); + mTimerFuture = null; + clearChartAndControls(); + } + } + + /** + * Primary method for receiving noise squelch state updates from the decoder. Manages the squelch state history + * buffer size. This method is invoked by the channel buffer processing thread and cooperates with the chart + * update timer thread by synchronizing/blocking on the squelch state history. + * @param noiseSquelchState to add to the history. + */ + @Override + public void receive(NoiseSquelchState noiseSquelchState) + { + synchronized(mSquelchStateHistory) + { + mSquelchStateHistory.add(noiseSquelchState); + + while(mSquelchStateHistory.size() > HISTORY_BUFFER_SIZE) + { + mSquelchStateHistory.removeFirst(); + } + } + + /** + * If this is the first squelch state, update the view controls with these initial values. + */ + if(!mControlsUpdated) + { + updateViewControls(noiseSquelchState); + } + } + + /** + * Initializes the view controls to the latest received noise squelch state which should always be accurate with the + * current state of the noise squelch. + * @param noiseSquelchState for initializing the controls. + */ + private void updateViewControls(NoiseSquelchState noiseSquelchState) + { + Platform.runLater(() -> { + + getOpenNoiseSlider().setDisable(false); + getCloseNoiseSlider().setDisable(false); + getNoiseValueLabel().setDisable(false); + setNoiseSliderValues(noiseSquelchState.noiseOpenThreshold(), noiseSquelchState.noiseCloseThreshold()); + + getOpenHysteresisSlider().setDisable(false); + getCloseHysteresisSlider().setDisable(false); + getHysteresisValueLabel().setDisable(false); + getOpenHysteresisSlider().setValue(noiseSquelchState.hysteresisOpenThreshold()); + + getCloseHysteresisSlider().setValue(noiseSquelchState.hysteresisCloseThreshold() - noiseSquelchState.hysteresisOpenThreshold()); + + getSquelchStateLabel().setDisable(false); + getActivityChart().setDisable(false); + + getSquelchOverrideButton().selectedProperty().setValue(noiseSquelchState.squelchOverride()); + getSquelchOverrideButton().setDisable(false); + getResetButton().setDisable(false); + + mControlsUpdated = true; + }); + } + + /** + * Clears any displayed chart data and disables the noise and hysteresis sliders and buttons. + */ + private void clearChartAndControls() + { + Platform.runLater(() -> { + synchronized(mSquelchStateHistory) + { + mSquelchStateHistory.clear(); + } + + updateChart(); + + //Hysteresis controls + getOpenHysteresisSlider().setDisable(true); + getOpenHysteresisSlider().setValue(0); + getCloseHysteresisSlider().setDisable(true); + getCloseHysteresisSlider().setValue(0); + getHysteresisValueLabel().setDisable(true); + + //Noise controls + getOpenNoiseSlider().setDisable(true); + getOpenNoiseSlider().setValue(0); + getCloseNoiseSlider().setValue(0); + getCloseNoiseSlider().setDisable(true); + getNoiseValueLabel().setDisable(true); + + getActivityChart().setDisable(true); + getSquelchOverrideButton().setDisable(true); + getSquelchStateLabel().setDisable(true); + getSquelchStateLabel().setText(NOT_AVAILABLE); + getResetButton().setDisable(true); + + mControlsUpdated = false; + + //Setting the slider values triggers a delayed label update. Submit a separate delayed update onto the + // UI thread queue to clear the labels after the slider value triggered label updates are processed. + Platform.runLater(() -> { + getHysteresisValueLabel().setText(NOT_AVAILABLE); + getNoiseValueLabel().setText(NOT_AVAILABLE); + }); + }); + } + + /** + * Updates the chart and label values from the noise squelch state history buffer. This method is fired by the + * scheduled timer process and cooperates squelch state history buffer thread access by synchronizing/blocking on the + * squelch state history buffer. + */ + private void updateChart() + { + final int[] hysteresis = new int[HISTORY_BUFFER_SIZE]; + final int[] hysteresisOpenThreshold = new int[HISTORY_BUFFER_SIZE]; + final int[] hysteresisCloseThreshold = new int[HISTORY_BUFFER_SIZE]; + final float[] noise = new float[HISTORY_BUFFER_SIZE]; + final float[] noiseOpenThreshold = new float[HISTORY_BUFFER_SIZE]; + final float[] noiseCloseThreshold = new float[HISTORY_BUFFER_SIZE]; + float noiseCurrent = 0f; + int hysteresisCurrent = 0; + + boolean squelch = true; + boolean squelchOverride = false; + + synchronized(mSquelchStateHistory) + { + if(!mSquelchStateHistory.isEmpty()) + { + NoiseSquelchState latest = mSquelchStateHistory.getLast(); + squelch = latest.squelch(); + squelchOverride = latest.squelchOverride(); + } + + if(mSquelchStateHistory.size() == HISTORY_BUFFER_SIZE) + { + for(int x = 0; x < HISTORY_BUFFER_SIZE; x++) + { + if(mSquelchStateHistory.size() > x) + { + NoiseSquelchState state = mSquelchStateHistory.get(x); + + noise[x] = toDisplayNoise(state.noise()); + noiseOpenThreshold[x] = toDisplayNoise(state.noiseOpenThreshold()); + noiseCloseThreshold[x] = toDisplayNoise(state.noiseCloseThreshold()); + + hysteresis[x] = state.hysteresis(); + hysteresisOpenThreshold[x] = state.hysteresisOpenThreshold(); + hysteresisCloseThreshold[x] = state.hysteresisCloseThreshold(); + + if(x == HISTORY_BUFFER_SIZE - 1) + { + noiseCurrent = toDisplayNoise(state.noise()); + hysteresisCurrent = state.hysteresis(); + } + } + } + } + else + { + //On startup, make the values stream in from the right instead of the left until the buffer is full. + int offset = 0; + + for(int x = 0; x < mSquelchStateHistory.size(); x++) + { + offset = x + (HISTORY_BUFFER_SIZE - mSquelchStateHistory.size()); + + NoiseSquelchState state = mSquelchStateHistory.get(x); + + noise[offset] = toDisplayNoise(state.noise()); + noiseOpenThreshold[offset] = toDisplayNoise(state.noiseOpenThreshold()); + noiseCloseThreshold[offset] = toDisplayNoise(state.noiseCloseThreshold()); + + hysteresis[offset] = state.hysteresis(); + hysteresisOpenThreshold[offset] = state.hysteresisOpenThreshold(); + hysteresisCloseThreshold[offset] = state.hysteresisCloseThreshold(); + + if(x == mSquelchStateHistory.size() - 1) + { + noiseCurrent = toDisplayNoise(state.noise()); + hysteresisCurrent = state.hysteresis(); + } + } + } + } + + final boolean finalSquelchOverride = squelchOverride; + final boolean finalSquelch = squelch; + final float noiseCurrentFinal = noiseCurrent; + final int hysteresisCurrentFinal = hysteresisCurrent; + + //Update the chart and label displays on the JavaFX thread. + Platform.runLater(() -> { + try + { + for(int x = 0; x < HISTORY_BUFFER_SIZE; x++) + { + mNoiseSeries.getData().get(x).setYValue(noise[x]); + mNoiseOpenThresholdSeries.getData().get(x).setYValue(noiseOpenThreshold[x]); + mNoiseCloseThresholdSeries.getData().get(x).setYValue(noiseCloseThreshold[x]); + mHysteresisSeries.getData().get(x).setYValue(hysteresis[x]); + mHysteresisOpenThresholdSeries.getData().get(x).setYValue(hysteresisOpenThreshold[x]); + mHysteresisCloseThresholdSeries.getData().get(x).setYValue(hysteresisCloseThreshold[x]); + } + + getSquelchStateLabel().setText(finalSquelchOverride ? "Override" : finalSquelch ? "Closed" : "Open"); + updateLabelNoise(noiseCurrentFinal); + updateLabelHysteresis(hysteresisCurrentFinal); + } + catch(Exception e) + { + LOGGER.error("Error updating audio squelch noise values in squelch view", e); + } + }); + } + + /** + * Sets the noise squelch controller for this view. If the controller is non-null, registers this view to receive + * state events from the controller. If a previous controller is already registered then it is unregistered before + * registering on the new controller. + * + * @param controller to set (non-null) or clear (null). + */ + public void setController(INoiseSquelchController controller) + { + try + { + if(mController != null) + { + mController.setNoiseSquelchStateListener(null); + } + + mController = controller; + + if(mController != null) + { + mControlsUpdated = false; + mController.setNoiseSquelchStateListener(this); + } + else + { + clearChartAndControls(); + } + } + catch(Exception e) + { + LOGGER.error("Error updating noise squelch controller", e); + } + + updateTimer(); + } + + /** + * Squelch default values reset button. + */ + private Button getResetButton() + { + if(mResetButton == null) + { + mResetButton = new Button(); + mResetButton.setDisable(true); + mResetButton.setMaxWidth(Double.MAX_VALUE); + mResetButton.setTooltip(new Tooltip("Reset noise and hysteresis to default values")); + IconNode iconNode = new IconNode(FontAwesome.UNDO); + iconNode.setIconSize(10); + iconNode.setFill(Color.BLACK); + mResetButton.setGraphic(iconNode); + mResetButton.setOnAction(event -> { + setNoiseSliderValues(NoiseSquelch.DEFAULT_NOISE_OPEN_THRESHOLD, NoiseSquelch.DEFAULT_NOISE_CLOSE_THRESHOLD); + getSquelchOverrideButton().setSelected(false); + getOpenHysteresisSlider().setValue(NoiseSquelch.DEFAULT_HYSTERESIS_OPEN_THRESHOLD); + //Note: close threshold slider values are added to the open slider, so we subtract open value from the absolute value + getCloseHysteresisSlider().setValue(NoiseSquelch.DEFAULT_HYSTERESIS_CLOSE_THRESHOLD - NoiseSquelch.DEFAULT_HYSTERESIS_OPEN_THRESHOLD); + }); + } + + return mResetButton; + } + + /** + * Squelch override button + */ + private ToggleButton getSquelchOverrideButton() + { + if(mSquelchOverrideButton == null) + { + mSquelchOverrideButton = new ToggleButton("Override"); + mSquelchOverrideButton.setTooltip(new Tooltip("Manually override squelch control for always-on audio output")); + mSquelchOverrideButton.setDisable(true); + mSquelchOverrideButton.setOnAction(event -> { + if(mController != null) + { + mController.setSquelchOverride(getSquelchOverrideButton().isSelected()); + } + }); + } + + return mSquelchOverrideButton; + } + + /** + * Open noise threshold slider. Values range from 0.0 to 0.15. Note: slider values are inverted and converted + * to the actual values used by the noise squelch control. + * @return slider control + */ + private Slider getOpenNoiseSlider() + { + if(mOpenNoiseSlider == null) + { + mOpenNoiseSlider = new Slider(0.0, NOISE_CONTROL_INVERSION_BASE, 0.0); + mOpenNoiseSlider.setDisable(true); + mOpenNoiseSlider.setTooltip(new Tooltip("Adjust the noise threshold for squelch opening")); + mOpenNoiseSlider.setMajorTickUnit(0.05); + mOpenNoiseSlider.setMinorTickCount(4); + mOpenNoiseSlider.setShowTickMarks(true); + mOpenNoiseSlider.valueProperty().addListener((observable, oldValue, newValue) -> updateControllerNoise()); + } + + return mOpenNoiseSlider; + } + + /** + * Updates the noise threshold on the controller from open/close slider change events. + */ + private void updateControllerNoise() + { + if(mController != null && mShowing) + { + float open = getOpenNoiseThresholdFromSliderValue(); + float close = getCloseNoiseThresholdFromSliderValue(); + open = Math.min(open, NoiseSquelch.MAXIMUM_NOISE_THRESHOLD); + open = Math.max(open, NoiseSquelch.MINIMUM_NOISE_THRESHOLD); + close = Math.max(open, close); + close = Math.min(close, NoiseSquelch.MAXIMUM_NOISE_THRESHOLD); + close = Math.max(close, NoiseSquelch.MINIMUM_NOISE_THRESHOLD); + mController.setNoiseThreshold(open, close); + + //The controller updates the channel configuration so schedule a playlist save + if(mPlaylistManager != null) + { + mPlaylistManager.schedulePlaylistSave(); + } + } + } + + /** + * Close noise threshold slider. Values range from 0.0 to 0.15. Note: slider values are inverted and converted + * to the actual values used by the noise squelch control. + * @return slider control + */ + private Slider getCloseNoiseSlider() + { + if(mCloseNoiseSlider == null) + { + mCloseNoiseSlider = new Slider(0.00, NOISE_CONTROL_INVERSION_BASE, 0.0); + mCloseNoiseSlider.setDisable(true); + mCloseNoiseSlider.setTooltip(new Tooltip("Adjust the noise threshold for squelch closing")); + mCloseNoiseSlider.setMajorTickUnit(0.05); + mCloseNoiseSlider.setMinorTickCount(4); + mCloseNoiseSlider.setShowTickMarks(true); + mCloseNoiseSlider.valueProperty().addListener((observable, oldValue, newValue) -> updateControllerNoise()); + } + + return mCloseNoiseSlider; + } + + /** + * Open hysteresis slider control + */ + private Slider getOpenHysteresisSlider() + { + if(mOpenHysteresisSlider == null) + { + mOpenHysteresisSlider = new Slider(1, 5, 0); + mOpenHysteresisSlider.setDisable(true); + mOpenHysteresisSlider.setTooltip(new Tooltip("Adjust the hysteresis threshold for squelch opening")); + mOpenHysteresisSlider.setBlockIncrement(1); + mOpenHysteresisSlider.setMajorTickUnit(1.0); + mOpenHysteresisSlider.setMinorTickCount(0); + mOpenHysteresisSlider.setShowTickMarks(true); + mOpenHysteresisSlider.valueProperty().addListener((observable, oldValue, newValue) -> updateControllerHysteresis()); + } + + return mOpenHysteresisSlider; + } + + /** + * Applies open and close hysteresis slider values to the noise squelch control when the user adjusts either value. + */ + private void updateControllerHysteresis() + { + if(mController != null && mShowing) + { + int open = (int)getOpenHysteresisSlider().getValue(); + int close = (int)getCloseHysteresisSlider().getValue() + open; + + if(open != mOpenHysteresisShadow || close != mCloseHysteresisShadow) + { + //Shadow copy to limit fire events to only true changes. + mOpenHysteresisShadow = open; + mCloseHysteresisShadow = close; + mController.setHysteresisThreshold(open, close); + + //The controller updates the channel configuration so schedule a playlist save + if(mPlaylistManager != null) + { + mPlaylistManager.schedulePlaylistSave(); + } + } + } + } + + /** + * Close hysteresis value slider control + */ + private Slider getCloseHysteresisSlider() + { + if(mCloseHysteresisSlider == null) + { + mCloseHysteresisSlider = new Slider(0, 5, 0); + mCloseHysteresisSlider.setDisable(true); + mCloseHysteresisSlider.setTooltip(new Tooltip("Adjust the hysteresis threshold for squelch closing")); + mCloseHysteresisSlider.setBlockIncrement(1); + mCloseHysteresisSlider.setMajorTickUnit(1.0); + mCloseHysteresisSlider.setMinorTickCount(0); + mCloseHysteresisSlider.setShowTickMarks(true); + mCloseHysteresisSlider.setDisable(true); + mCloseHysteresisSlider.valueProperty().addListener((observable, oldValue, newValue) -> updateControllerHysteresis()); + } + + return mCloseHysteresisSlider; + } + + /** + * Label to display the squelch state. + */ + private Label getSquelchStateLabel() + { + if(mSquelchStateLabel == null) + { + mSquelchStateLabel = new Label(NOT_AVAILABLE); + mSquelchStateLabel.setDisable(true); + mSquelchStateLabel.setStyle("-fx-font-weight: bold;"); + mSquelchStateLabel.setDisable(true); + } + + return mSquelchStateLabel; + } + + /** + * Label to display the open, close and current noise values. + */ + private Label getNoiseValueLabel() + { + if(mNoiseValueLabel == null) + { + mNoiseValueLabel = new Label(NOT_AVAILABLE); + mNoiseValueLabel.setDisable(true); + mNoiseValueLabel.setPadding(new Insets(0, 0, 0, 3)); + getOpenNoiseSlider().valueProperty().addListener((observable, oldValue, newValue) -> updateLabelNoise(0.0f)); + getCloseNoiseSlider().valueProperty().addListener((observable, oldValue, newValue) -> updateLabelNoise(0.0f)); + } + + return mNoiseValueLabel; + } + + /** + * Label to display the open, close and current hysteresis values. + */ + private Label getHysteresisValueLabel() + { + if(mHysteresisValueLabel == null) + { + mHysteresisValueLabel = new Label(NOT_AVAILABLE); + mHysteresisValueLabel.setDisable(true); + mHysteresisValueLabel.setPadding(new Insets(0, 0, 0, 3)); + getOpenHysteresisSlider().valueProperty().addListener((observable, oldValue, newValue) -> updateLabelHysteresis(0)); + getCloseHysteresisSlider().valueProperty().addListener((observable, oldValue, newValue) -> updateLabelHysteresis(0)); + } + + return mHysteresisValueLabel; + } + + /** + * Updates the hysteresis value label content. + * @param current hysteresis value. + */ + private void updateLabelHysteresis(int current) + { + int open = (int)getOpenHysteresisSlider().getValue(); + int close = (int)getCloseHysteresisSlider().getValue() + open; + getHysteresisValueLabel().setText(open + " - " + close + " Current: " + current + " (x" + NoiseSquelch.VARIANCE_CALCULATION_WINDOW_MILLISECONDS + "ms)"); + } + + /** + * Updates the noise value label content. + * @param current noise value converted to display format. + */ + private void updateLabelNoise(float current) + { + float open = getOpenNoiseThresholdFromSliderValue(); + float close = getCloseNoiseThresholdFromSliderValue(); + getNoiseValueLabel().setText(DECIMAL_FORMAT.format(toDisplayNoise(close)) + " - " + + DECIMAL_FORMAT.format(toDisplayNoise(open)) + " Current: " + + DECIMAL_FORMAT.format(Math.abs(current))); + } + + /** + * Converts the current open threshold slider value into the value used by the noise squelch control. + * @return current open threshold setting. + */ + private float getOpenNoiseThresholdFromSliderValue() + { + return (float)(NOISE_CONTROL_OPEN_OFFSET + (NOISE_CONTROL_INVERSION_BASE - getOpenNoiseSlider().getValue())); + } + + /** + * Updates the open and close noise threshold slider controls from the noise squelch threshold values. + * @param open noise squelch threshold + * @param close noise squelch threshold + */ + private void setNoiseSliderValues(float open, float close) + { + float openSlider = NOISE_CONTROL_INVERSION_BASE - (open - NOISE_CONTROL_OPEN_OFFSET); + float closeSlider = NOISE_CONTROL_INVERSION_BASE - (close - open); + + openSlider = Math.min(openSlider, NOISE_CONTROL_INVERSION_BASE); + openSlider = Math.max(openSlider, 0f); + closeSlider = Math.min(closeSlider, NOISE_CONTROL_INVERSION_BASE); + closeSlider = Math.max(closeSlider, 0f); + + getOpenNoiseSlider().setValue(openSlider); + getCloseNoiseSlider().setValue(closeSlider); + } + + /** + * Converts the current close threshold slider value into the value used by the noise squelch control. + * @return current close threshold setting. + */ + private float getCloseNoiseThresholdFromSliderValue() + { + return getOpenNoiseThresholdFromSliderValue() + (float)(NOISE_CONTROL_INVERSION_BASE - getCloseNoiseSlider().getValue()); + } + + /** + * Converts from the noise squelch controller noise value to the display noise value. + * @param noiseValue from the controller + * @return value for display + */ + private static float toDisplayNoise(float noiseValue) + { + return NOISE_INVERSION_BASE - (noiseValue * NOISE_DISPLAY_SCALOR); + } + + /** + * Line chart displaying combined noise and hysteresis history. Uses 6x lines to display open, close and current + * values for noise and hysteresis. + */ + private LineChart getActivityChart() + { + if(mActivityChart == null) + { + NumberAxis xAxis = new NumberAxis(0, HISTORY_BUFFER_SIZE - 1, 0); + NumberAxis yAxis = new NumberAxis(-0.5, 10.5, 1); + mActivityChart = new LineChart<>(xAxis, yAxis); + mActivityChart.setLegendSide(Side.RIGHT); + mActivityChart.setPadding(new Insets(0, 5, 0, 0)); + mActivityChart.setAnimated(false); //Turn off animation + mActivityChart.setCreateSymbols(false); //Turn off data point markers + mActivityChart.setMaxHeight(Double.MAX_VALUE); + mActivityChart.setMaxWidth(Double.MAX_VALUE); + mActivityChart.lookup(".chart-plot-background").setStyle("-fx-background-color: black;"); + + for(int x = 0; x < HISTORY_BUFFER_SIZE; x++) + { + mNoiseSeries.getData().add(new XYChart.Data<>(x, 0)); + mNoiseOpenThresholdSeries.getData().add(new XYChart.Data<>(x, 0)); + mNoiseCloseThresholdSeries.getData().add(new XYChart.Data<>(x, 0)); + mHysteresisSeries.getData().add(new XYChart.Data<>(x, 0)); + mHysteresisOpenThresholdSeries.getData().add(new XYChart.Data<>(x, 0)); + mHysteresisCloseThresholdSeries.getData().add(new XYChart.Data<>(x, 0)); + } + + mNoiseSeries.setName("Noise (N)"); + mNoiseOpenThresholdSeries.setName("N-Open"); + mNoiseCloseThresholdSeries.setName("N-Close"); + + mHysteresisSeries.setName("Hysteresis"); + mHysteresisOpenThresholdSeries.setName("H-Open"); + mHysteresisCloseThresholdSeries.setName("H-Close"); + + mActivityChart.getData().add(mNoiseSeries); + mActivityChart.getData().add(mNoiseOpenThresholdSeries); + mActivityChart.getData().add(mNoiseCloseThresholdSeries); + mActivityChart.getData().add(mHysteresisSeries); + mActivityChart.getData().add(mHysteresisCloseThresholdSeries); + mActivityChart.getData().add(mHysteresisOpenThresholdSeries); + } + + return mActivityChart; + } +} diff --git a/src/main/java/io/github/dsheirer/module/ProcessingChain.java b/src/main/java/io/github/dsheirer/module/ProcessingChain.java index 13b6d3e3..f281545f 100644 --- a/src/main/java/io/github/dsheirer/module/ProcessingChain.java +++ b/src/main/java/io/github/dsheirer/module/ProcessingChain.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -47,6 +47,7 @@ import io.github.dsheirer.message.IMessage; import io.github.dsheirer.message.IMessageListener; import io.github.dsheirer.message.IMessageProvider; import io.github.dsheirer.message.MessageHistory; +import io.github.dsheirer.module.decode.PrimaryDecoder; import io.github.dsheirer.module.decode.event.DecodeEventHistory; import io.github.dsheirer.module.decode.event.IDecodeEvent; import io.github.dsheirer.module.decode.event.IDecodeEventListener; @@ -168,6 +169,23 @@ public class ProcessingChain implements Listener return mChannelState; } + /** + * Retrieves the primary decoder module + * @return primary decoder or null. + */ + public PrimaryDecoder getPrimaryDecoder() + { + for(Module module : mModules) + { + if(module instanceof PrimaryDecoder primaryDecoder) + { + return primaryDecoder; + } + } + + return null; + } + /** * Decode event history module. */ diff --git a/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java b/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java index 5c75f405..64aa2b9c 100644 --- a/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java +++ b/src/main/java/io/github/dsheirer/module/decode/DecoderFactory.java @@ -447,18 +447,19 @@ public class DecoderFactory * @param aliasList for the channel * @param decodeConfig for the channel */ - private static void processAM(Channel channel, List modules, AliasList aliasList, DecodeConfiguration decodeConfig) { - - if(!(decodeConfig instanceof DecodeConfigAM)) + private static void processAM(Channel channel, List modules, AliasList aliasList, DecodeConfiguration decodeConfig) + { + if(decodeConfig instanceof DecodeConfigAM configAM) + { + modules.add(new AMDecoder(configAM)); + modules.add(new AMDecoderState(channel.getName(), configAM)); + modules.add(new AudioModule(aliasList, 0, 60000, AUDIO_FILTER_ENABLE)); + } + else { throw new IllegalArgumentException("Can't create AM decoder - unrecognized decode config type: " + (decodeConfig != null ? decodeConfig.getClass() : "null/empty")); } - - DecodeConfigAM decodeConfigAM = (DecodeConfigAM) decodeConfig; - modules.add(new AMDecoder(decodeConfigAM)); - modules.add(new AMDecoderState(channel.getName(), decodeConfigAM)); - modules.add(new AudioModule(aliasList, 0, 60000, AUDIO_FILTER_ENABLE)); } /** @@ -758,9 +759,12 @@ public class DecoderFactory case NBFM: DecodeConfigNBFM origNBFM = (DecodeConfigNBFM)config; DecodeConfigNBFM copyNBFM = new DecodeConfigNBFM(); + copyNBFM.setAudioFilter(origNBFM.isAudioFilter()); copyNBFM.setBandwidth(origNBFM.getBandwidth()); - copyNBFM.setSquelchThreshold(origNBFM.getSquelchThreshold()); - copyNBFM.setSquelchAutoTrack(origNBFM.isSquelchAutoTrack()); + copyNBFM.setSquelchHysteresisCloseThreshold(origNBFM.getSquelchHysteresisCloseThreshold()); + copyNBFM.setSquelchHysteresisOpenThreshold(origNBFM.getSquelchHysteresisOpenThreshold()); + copyNBFM.setSquelchNoiseOpenThreshold(origNBFM.getSquelchNoiseOpenThreshold()); + copyNBFM.setSquelchNoiseCloseThreshold(origNBFM.getSquelchNoiseCloseThreshold()); copyNBFM.setTalkgroup(origNBFM.getTalkgroup()); return copyNBFM; case P25_PHASE1: diff --git a/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java b/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java index 5a409e6f..51be14f2 100644 --- a/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java +++ b/src/main/java/io/github/dsheirer/module/decode/am/AMDecoder.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -16,34 +16,76 @@ * along with this program. If not, see * **************************************************************************** */ - 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.SquelchingAMDemodulator; +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.analog.SquelchingAnalogDecoder; +import io.github.dsheirer.module.decode.PrimaryDecoder; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.sample.complex.ComplexSamples; +import io.github.dsheirer.sample.complex.IComplexSamplesListener; +import io.github.dsheirer.sample.real.IRealBufferProvider; +import io.github.dsheirer.source.ISourceEventListener; +import io.github.dsheirer.source.ISourceEventProvider; +import io.github.dsheirer.source.SourceEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Analog AM radio decoder module with integrated squelching control + * AM decoder module with integrated squelch control */ -public class AMDecoder extends SquelchingAnalogDecoder +public class AMDecoder extends PrimaryDecoder implements ISourceEventListener, ISourceEventProvider, + IComplexSamplesListener, Listener, IRealBufferProvider, + IDecoderStateEventProvider, INoiseSquelchController { + 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; + private IRealDecimationFilter mIDecimationFilter; + private IRealDecimationFilter mQDecimationFilter; + private final ISquelchingDemodulator mDemodulator; + private RealResampler mResampler; + private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor(); + private Listener mResampledBufferListener; + private Listener mDecoderStateEventListener; + private final double mChannelBandwidth; + protected boolean mSquelch = true; + private final NoiseSquelch mNoiseSquelch = new NoiseSquelch(0.1f, 0.2f, 4, 6); /** * Constructs an instance - * @param config for the AM channel. + * + * @param config to setup the AM decoder */ public AMDecoder(DecodeConfigAM config) { - super(config, new SquelchingAMDemodulator(DEMODULATOR_GAIN, SQUELCH_ALPHA_DECAY, - config.getSquelchThreshold(), config.isSquelchAutoTrack())); + super(config); + mDemodulator = new SquelchingAMDemodulator(DEMODULATOR_GAIN, SQUELCH_ALPHA_DECAY, config.getSquelchThreshold(), + config.isSquelchAutoTrack()); + mChannelBandwidth = config.getBandwidth().getValue(); } @Override @@ -52,24 +94,365 @@ public class AMDecoder extends SquelchingAnalogDecoder return DecoderType.AM; } - /** - * Overrides the broadcast of the final demodulated audio samples so that we can apply AGC - * @param demodulatedSamples to broadcast - */ @Override - protected void broadcast(float[] demodulatedSamples) + public void setSquelchOverride(boolean override) { - //Apply audio gain and rebroadcast - super.broadcast(mAGC.process(demodulatedSamples)); + 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 listener) + { + mNoiseSquelch.setNoiseSquelchStateListener(listener); } /** - * Overrides the notify call start so that we can reset the AGC + * Implements the ISourceEventListener interface */ @Override + public Listener getSourceEventListener() + { + return mSourceEventProcessor; + } + + /** + * Module interface methods - unused. + */ + @Override + public void reset() + { + } + + @Override + public void start() + { + } + + @Override + public void stop() + { + } + + /** + * Broadcasts the demodulated audio samples to the registered listener. + * @param demodulatedSamples to broadcast + */ + protected void broadcast(float[] demodulatedSamples) + { + if(mResampledBufferListener != null) + { + mResampledBufferListener.receive(demodulatedSamples); +// //Apply audio gain and rebroadcast +// super.broadcast(mAGC.process(demodulatedSamples)); + } + } + + /** + * Implements the IRealBufferProvider interface to register a listener for demodulated audio samples. + * + * @param listener to receive demodulated audio sample buffers. + */ + @Override + public void setBufferListener(Listener listener) + { + mResampledBufferListener = listener; + } + + /** + * Implements the IRealBufferProvider interface to deregister a listener from receiving demodulated audio samples. + */ + @Override + public void removeBufferListener() + { + mResampledBufferListener = null; + } + + /** + * Implements the IComplexSampleListener interface to receive a stream of complex sample buffers. + */ + @Override + public Listener getComplexSamplesListener() + { + return this; + } + + /** + * Implements the Listener interface to receive a stream of complex sample buffers + */ + @Override + public void receive(ComplexSamples samples) + { + if(mIDecimationFilter == null || mQDecimationFilter == null) + { + throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source " + + "event before it can process complex sample buffers"); + } + + float[] decimatedI = mIDecimationFilter.decimateReal(samples.i()); + float[] decimatedQ = mQDecimationFilter.decimateReal(samples.q()); + + float[] filteredI = mIBasebandFilter.filter(decimatedI); + float[] filteredQ = mQBasebandFilter.filter(decimatedQ); + + float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ); + + mNoiseSquelch.process(demodulated); + + if(mResampler != null) + { + //Squelch changed while processing this audio buffer + if(mDemodulator.isSquelchChanged()) + { + if(mDemodulator.isMuted()) + { + //Demodulator says to mute and we're already muted/squelched = Continue IDLE + if(mSquelch) + { + notifyIdle(); + } + //Demodulator says to mute and we are unmuted = Squelch and End Call + else + { + mSquelch = true; + notifyCallEnd(); + } + } + else + { + //Demodulator says to unmute and we're muted/squelched = Unmute and Start Call + if(mSquelch) + { + mSquelch = false; + notifyCallStart(); + } + //Demodulator says to unmute and we're already unmuted = Continue Call + else + { + notifyCallContinuation(); + } + + mResampler.resample(demodulated); + } + } + else + { + //Demodulator says squelch state didn't change and we're muted/squelched = Continue IDLE + if(mSquelch) + { + notifyIdle(); + } + //Demodulator says squelch state didn't change and we're unmuted/unsquelched = Continue CALL + else + { + notifyCallContinuation(); + mResampler.resample(demodulated); + } + } + } + else + { + //This shouldn't happen + notifyIdle(); + } + + + } + + /** + * Broadcasts a call start state event + */ protected void notifyCallStart() { mAGC.reset(); - super.notifyCallStart(); + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0)); + } + + /** + * Broadcasts a call continuation state event + */ + private void notifyCallContinuation() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.CALL, 0)); + } + + /** + * Broadcasts a call end state event + */ + private void notifyCallEnd() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.END, State.CALL, 0)); + } + + /** + * Broadcasts an idle notification + */ + private void notifyIdle() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.IDLE, 0)); + } + + /** + * Broadcasts the decoder state event to an optional registered listener + */ + private void broadcast(DecoderStateEvent event) + { + if(mDecoderStateEventListener != null) + { + mDecoderStateEventListener.receive(event); + } + } + + /** + * Sets the decoder state listener + */ + @Override + public void setDecoderStateListener(Listener listener) + { + mDecoderStateEventListener = listener; + } + + /** + * Removes the decoder state event listener + */ + @Override + public void removeDecoderStateListener() + { + mDecoderStateEventListener = null; + } + + /** + * Registers the listener to receive squelch change events from the demodulator/squelch controller. + */ + @Override + public void setSourceEventListener(Listener listener) + { + mDemodulator.setSourceEventListener(listener); + } + + /** + * De-registers the listener + */ + @Override + public void removeSourceEventListener() + { + mDemodulator.setSourceEventListener(null); + } + + /** + * Monitors sample rate change source event(s) to setup the initial I/Q filter and passes squelch threshold + * change requests down to the demodulator. + */ + public class SourceEventProcessor implements Listener + { + @Override + public void receive(SourceEvent sourceEvent) + { + switch(sourceEvent.getEvent()) + { + //Forward these requests to the sub-class implementation (AM or FM demod) to handle + case REQUEST_CURRENT_SQUELCH_THRESHOLD: + case REQUEST_CHANGE_SQUELCH_THRESHOLD: + case REQUEST_CURRENT_SQUELCH_AUTO_TRACK: + case REQUEST_CHANGE_SQUELCH_AUTO_TRACK: + mDemodulator.receive(sourceEvent); + break; + case NOTIFICATION_SAMPLE_RATE_CHANGE: + if(mIBasebandFilter != null) + { + mIBasebandFilter = null; + mQBasebandFilter = null; + } + + double sampleRate = sourceEvent.getValue().doubleValue(); + + int decimationRate = 0; + double decimatedSampleRate = sampleRate; + + if(sampleRate / 2 >= (mChannelBandwidth * 2)) + { + decimationRate = 2; + + while(sampleRate / decimationRate / 2 >= (mChannelBandwidth * 2)) + { + decimationRate *= 2; + } + } + + if(decimationRate > 0) + { + decimatedSampleRate /= decimationRate; + } + + mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); + mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); + + if((decimatedSampleRate < (2.0 * 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")); + } + + 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); + } + + mIBasebandFilter = FilterFactory.getRealFilter(coefficients); + mQBasebandFilter = FilterFactory.getRealFilter(coefficients); + + mResampler = new RealResampler(decimatedSampleRate, DEMODULATED_AUDIO_SAMPLE_RATE, 4192, 512); + mResampler.setListener(resampled -> broadcast(resampled)); + break; + } + } } } diff --git a/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java b/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java index bfbe2633..d34a88e3 100644 --- a/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java +++ b/src/main/java/io/github/dsheirer/module/decode/am/DecodeConfigAM.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -20,6 +20,7 @@ package io.github.dsheirer.module.decode.am; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import io.github.dsheirer.dsp.squelch.ISquelchConfiguration; import io.github.dsheirer.module.decode.DecoderType; import io.github.dsheirer.module.decode.analog.DecodeConfigAnalog; import io.github.dsheirer.source.tuner.channel.ChannelSpecification; @@ -27,8 +28,11 @@ import io.github.dsheirer.source.tuner.channel.ChannelSpecification; /** * AM decoder configuration */ -public class DecodeConfigAM extends DecodeConfigAnalog +public class DecodeConfigAM extends DecodeConfigAnalog implements ISquelchConfiguration { + private int mSquelchThreshold = -78; + private boolean mSquelchAutoTrack = true; + public DecodeConfigAM() { } @@ -68,4 +72,46 @@ public class DecodeConfigAM extends DecodeConfigAnalog throw new IllegalArgumentException("Unrecognized AM bandwidth value: " + getBandwidth()); } } + + /** + * Sets squelch threshold + * @param threshold (dB) + */ + @JacksonXmlProperty(isAttribute = true, localName = "squelch") + @Override + public void setSquelchThreshold(int threshold) + { + mSquelchThreshold = threshold; + } + + /** + * Squelch threshold + * @return threshold (dB) + */ + @Override + public int getSquelchThreshold() + { + return mSquelchThreshold; + } + + /** + * Enable or disable the squelch noise floor auto-track feature. + * @param autoTrack true to enable. + */ + @JacksonXmlProperty(isAttribute = true, localName = "autoTrack") + @Override + public void setSquelchAutoTrack(boolean autoTrack) + { + mSquelchAutoTrack = autoTrack; + } + + /** + * Indicates if the squelch noise floor auto-track feature is enabled. + * @return true if enabled. + */ + @Override + public boolean isSquelchAutoTrack() + { + return mSquelchAutoTrack; + } } diff --git a/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java b/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java index c4e20c1b..bf3c3a8c 100644 --- a/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java +++ b/src/main/java/io/github/dsheirer/module/decode/analog/DecodeConfigAnalog.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -20,7 +20,6 @@ package io.github.dsheirer.module.decode.analog; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; -import io.github.dsheirer.dsp.squelch.ISquelchConfiguration; import io.github.dsheirer.module.decode.config.DecodeConfiguration; import io.github.dsheirer.source.tuner.channel.ChannelSpecification; import java.util.EnumSet; @@ -28,12 +27,10 @@ import java.util.EnumSet; /** * Decoder configuration for an squelching analog channel */ -public abstract class DecodeConfigAnalog extends DecodeConfiguration implements ISquelchConfiguration +public abstract class DecodeConfigAnalog extends DecodeConfiguration { private Bandwidth mBandwidth; private int mTalkgroup = 1; - private int mSquelchThreshold = -78; - private boolean mSquelchAutoTrack = true; /** * Constructs an instance @@ -99,48 +96,6 @@ public abstract class DecodeConfigAnalog extends DecodeConfiguration implements mTalkgroup = talkgroup; } - /** - * Sets squelch threshold - * @param threshold (dB) - */ - @JacksonXmlProperty(isAttribute = true, localName = "squelch") - @Override - public void setSquelchThreshold(int threshold) - { - mSquelchThreshold = threshold; - } - - /** - * Squelch threshold - * @return threshold (dB) - */ - @Override - public int getSquelchThreshold() - { - return mSquelchThreshold; - } - - /** - * Enable or disable the squelch noise floor auto-track feature. - * @param autoTrack true to enable. - */ - @JacksonXmlProperty(isAttribute = true, localName = "autoTrack") - @Override - public void setSquelchAutoTrack(boolean autoTrack) - { - mSquelchAutoTrack = autoTrack; - } - - /** - * Indicates if the squelch noise floor auto-track feature is enabled. - * @return true if enabled. - */ - @Override - public boolean isSquelchAutoTrack() - { - return mSquelchAutoTrack; - } - public enum Bandwidth { BW_3_0("3.0 kHz", 3000.0), diff --git a/src/main/java/io/github/dsheirer/module/decode/analog/SquelchingAnalogDecoder.java b/src/main/java/io/github/dsheirer/module/decode/analog/SquelchingAnalogDecoder.java deleted file mode 100644 index 889e739f..00000000 --- a/src/main/java/io/github/dsheirer/module/decode/analog/SquelchingAnalogDecoder.java +++ /dev/null @@ -1,410 +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 - * **************************************************************************** - */ -package io.github.dsheirer.module.decode.analog; - -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.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.window.WindowType; -import io.github.dsheirer.module.decode.PrimaryDecoder; -import io.github.dsheirer.sample.Listener; -import io.github.dsheirer.sample.complex.ComplexSamples; -import io.github.dsheirer.sample.complex.IComplexSamplesListener; -import io.github.dsheirer.sample.real.IRealBufferProvider; -import io.github.dsheirer.source.ISourceEventListener; -import io.github.dsheirer.source.ISourceEventProvider; -import io.github.dsheirer.source.SourceEvent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Abstract analog decoder module with integrated squelch control - */ -public abstract class SquelchingAnalogDecoder extends PrimaryDecoder implements ISourceEventListener, ISourceEventProvider, - IComplexSamplesListener, Listener, IRealBufferProvider, - IDecoderStateEventProvider -{ - private final static Logger mLog = LoggerFactory.getLogger(SquelchingAnalogDecoder.class); - private static final double DEMODULATED_AUDIO_SAMPLE_RATE = 8000.0; - private IRealFilter mIBasebandFilter; - private IRealFilter mQBasebandFilter; - private IRealDecimationFilter mIDecimationFilter; - private IRealDecimationFilter mQDecimationFilter; - private ISquelchingDemodulator mDemodulator; - private RealResampler mResampler; - private SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor(); - private Listener mResampledBufferListener; - private Listener mDecoderStateEventListener; - private double mChannelBandwidth; - private double mOutputSampleRate = DEMODULATED_AUDIO_SAMPLE_RATE; - protected boolean mSquelch = true; - - /** - * Constructs an instance - * - * @param config to setup the decoder - * @param squelchingDemodulator implementation (e.g. AM or FM). - */ - public SquelchingAnalogDecoder(DecodeConfigAnalog config, ISquelchingDemodulator squelchingDemodulator) - { - super(config); - mDemodulator = squelchingDemodulator; - mChannelBandwidth = config.getBandwidth().getValue(); - mDemodulator.setSquelchThreshold(config.getSquelchThreshold()); - } - - /** - * Implements the ISourceEventListener interface - */ - @Override - public Listener getSourceEventListener() - { - return mSourceEventProcessor; - } - - /** - * Module interface methods - unused. - */ - @Override - public void reset() - { - } - - @Override - public void start() - { - } - - @Override - public void stop() - { - } - - /** - * Broadcasts the demodulated audio samples to the registered listener. - * @param demodulatedSamples to broadcast - */ - protected void broadcast(float[] demodulatedSamples) - { - if(mResampledBufferListener != null) - { - mResampledBufferListener.receive(demodulatedSamples); - } - } - - /** - * Implements the IRealBufferProvider interface to register a listener for demodulated audio samples. - * - * @param listener to receive demodulated audio sample buffers. - */ - @Override - public void setBufferListener(Listener listener) - { - mResampledBufferListener = listener; - } - - /** - * Implements the IRealBufferProvider interface to deregister a listener from receiving demodulated audio samples. - */ - @Override - public void removeBufferListener() - { - mResampledBufferListener = null; - } - - /** - * Implements the IComplexSampleListener interface to receive a stream of complex sample buffers. - */ - @Override - public Listener getComplexSamplesListener() - { - return this; - } - - /** - * Implements the Listener interface to receive a stream of complex sample buffers - */ - @Override - public void receive(ComplexSamples samples) - { - if(mIDecimationFilter == null || mQDecimationFilter == null) - { - throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source " + - "event before it can process complex sample buffers"); - } - - float[] decimatedI = mIDecimationFilter.decimateReal(samples.i()); - float[] decimatedQ = mQDecimationFilter.decimateReal(samples.q()); - - float[] filteredI = mIBasebandFilter.filter(decimatedI); - float[] filteredQ = mQBasebandFilter.filter(decimatedQ); - - float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ); - - if(mResampler != null) - { - //Squelch changed while processing this audio buffer - if(mDemodulator.isSquelchChanged()) - { - if(mDemodulator.isMuted()) - { - //Demodulator says to mute and we're already muted/squelched = Continue IDLE - if(mSquelch) - { - notifyIdle(); - } - //Demodulator says to mute and we are unmuted = Squelch and End Call - else - { - mSquelch = true; - notifyCallEnd(); - } - } - else - { - //Demodulator says to unmute and we're muted/squelched = Unmute and Start Call - if(mSquelch) - { - mSquelch = false; - notifyCallStart(); - } - //Demodulator says to unmute and we're already unmuted = Continue Call - else - { - notifyCallContinuation(); - } - - mResampler.resample(demodulated); - } - } - else - { - //Demodulator says squelch state didn't change and we're muted/squelched = Continue IDLE - if(mSquelch) - { - notifyIdle(); - } - //Demodulator says squelch state didn't change and we're unmuted/unsquelched = Continue CALL - else - { - notifyCallContinuation(); - mResampler.resample(demodulated); - } - } - } - else - { - //This shouldn't happen - notifyIdle(); - } - - - } - - /** - * Broadcasts a call start state event - */ - protected void notifyCallStart() - { - broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0)); - } - - /** - * Broadcasts a call continuation state event - */ - private void notifyCallContinuation() - { - broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.CALL, 0)); - } - - /** - * Broadcasts a call end state event - */ - private void notifyCallEnd() - { - broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.END, State.CALL, 0)); - } - - /** - * Broadcasts an idle notification - */ - private void notifyIdle() - { - broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.IDLE, 0)); - } - - /** - * Broadcasts the decoder state event to an optional registered listener - */ - private void broadcast(DecoderStateEvent event) - { - if(mDecoderStateEventListener != null) - { - mDecoderStateEventListener.receive(event); - } - } - - /** - * Sets the decoder state listener - */ - @Override - public void setDecoderStateListener(Listener listener) - { - mDecoderStateEventListener = listener; - } - - /** - * Removes the decoder state event listener - */ - @Override - public void removeDecoderStateListener() - { - mDecoderStateEventListener = null; - } - - /** - * Registers the listener to receive squelch change events from the demodulator/squelch controller. - */ - @Override - public void setSourceEventListener(Listener listener) - { - mDemodulator.setSourceEventListener(listener); - } - - /** - * De-registers the listener - */ - @Override - public void removeSourceEventListener() - { - mDemodulator.setSourceEventListener(null); - } - - /** - * Monitors sample rate change source event(s) to setup the initial I/Q filter and passes squelch threshold - * change requests down to the demodulator. - */ - public class SourceEventProcessor implements Listener - { - @Override - public void receive(SourceEvent sourceEvent) - { - switch(sourceEvent.getEvent()) - { - //Forward these requests to the sub-class implementation (AM or FM demod) to handle - case REQUEST_CURRENT_SQUELCH_THRESHOLD: - case REQUEST_CHANGE_SQUELCH_THRESHOLD: - case REQUEST_CURRENT_SQUELCH_AUTO_TRACK: - case REQUEST_CHANGE_SQUELCH_AUTO_TRACK: - mDemodulator.receive(sourceEvent); - break; - case NOTIFICATION_SAMPLE_RATE_CHANGE: - if(mIBasebandFilter != null) - { - mIBasebandFilter = null; - mQBasebandFilter = null; - } - - double sampleRate = sourceEvent.getValue().doubleValue(); - - int decimationRate = 0; - double decimatedSampleRate = sampleRate; - - if(sampleRate / 2 >= (mChannelBandwidth * 2)) - { - decimationRate = 2; - - while(sampleRate / decimationRate / 2 >= (mChannelBandwidth * 2)) - { - decimationRate *= 2; - } - } - - if(decimationRate > 0) - { - decimatedSampleRate /= decimationRate; - } - - mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); - mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); - - if((decimatedSampleRate < (2.0 * 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")); - } - - mDemodulator.setSampleRate((int) 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); - } - - mIBasebandFilter = FilterFactory.getRealFilter(coefficients); - mQBasebandFilter = FilterFactory.getRealFilter(coefficients); - - mResampler = new RealResampler(decimatedSampleRate, mOutputSampleRate, 4192, 512); - mResampler.setListener(resampled -> broadcast(resampled)); - break; - } - } - } -} diff --git a/src/main/java/io/github/dsheirer/module/decode/nbfm/DecodeConfigNBFM.java b/src/main/java/io/github/dsheirer/module/decode/nbfm/DecodeConfigNBFM.java index 739dc9b6..e0a22c4d 100644 --- a/src/main/java/io/github/dsheirer/module/decode/nbfm/DecodeConfigNBFM.java +++ b/src/main/java/io/github/dsheirer/module/decode/nbfm/DecodeConfigNBFM.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -20,6 +20,7 @@ package io.github.dsheirer.module.decode.nbfm; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import io.github.dsheirer.dsp.squelch.NoiseSquelch; import io.github.dsheirer.module.decode.DecoderType; import io.github.dsheirer.module.decode.analog.DecodeConfigAnalog; import io.github.dsheirer.source.tuner.channel.ChannelSpecification; @@ -30,6 +31,10 @@ import io.github.dsheirer.source.tuner.channel.ChannelSpecification; public class DecodeConfigNBFM extends DecodeConfigAnalog { private boolean mAudioFilter = true; + private float mSquelchNoiseOpenThreshold = NoiseSquelch.DEFAULT_NOISE_OPEN_THRESHOLD; + private float mSquelchNoiseCloseThreshold = NoiseSquelch.DEFAULT_NOISE_CLOSE_THRESHOLD; + private int mSquelchHysteresisOpenThreshold = NoiseSquelch.DEFAULT_HYSTERESIS_OPEN_THRESHOLD; + private int mSquelchHysteresisCloseThreshold = NoiseSquelch.DEFAULT_HYSTERESIS_CLOSE_THRESHOLD; /** * Constructs an instance @@ -88,4 +93,100 @@ public class DecodeConfigNBFM extends DecodeConfigAnalog { mAudioFilter = audioFilter; } + + /** + * Squelch noise open threshold in the range 0.0 to 1.0 with a default of 0.1 + * @return noise open threshold + */ + @JacksonXmlProperty(isAttribute = true, localName = "squelchNoiseOpenThreshold") + public float getSquelchNoiseOpenThreshold() + { + return mSquelchNoiseOpenThreshold; + } + + /** + * Squelch noise close threshold in the range 0.0 to 1.0, greater than or equal to open threshold, with a default of 0.2 + * @return noise close threshold + */ + @JacksonXmlProperty(isAttribute = true, localName = "squelchNoiseCloseThreshold") + public float getSquelchNoiseCloseThreshold() + { + return mSquelchNoiseCloseThreshold; + } + + /** + * Sets the squelch noise threshold. + * @param open in range 0.0 to 1.0 with a default of 0.1 + */ + public void setSquelchNoiseOpenThreshold(float open) + { + if(open < NoiseSquelch.MINIMUM_NOISE_THRESHOLD || open > NoiseSquelch.MAXIMUM_NOISE_THRESHOLD) + { + throw new IllegalArgumentException("Squelch noise open threshold is out of range: " + open); + } + + mSquelchNoiseOpenThreshold = open; + } + + /** + * Sets the squelch noise close threshold. + * @param close in range 0.0 to 1.0 and greater than or equal to open, with a default of 0.1 + */ + public void setSquelchNoiseCloseThreshold(float close) + { + if(close < NoiseSquelch.MINIMUM_NOISE_THRESHOLD || close > NoiseSquelch.MAXIMUM_NOISE_THRESHOLD) + { + throw new IllegalArgumentException("Squelch noise close threshold is out of range: " + close); + } + + mSquelchNoiseCloseThreshold = close; + } + + /** + * Squelch hysteresis open threshold in range 1-10 with a default of 4. + * @return hysteresis open threshold + */ + @JacksonXmlProperty(isAttribute = true, localName = "squelchHysteresisOpenThreshold") + public int getSquelchHysteresisOpenThreshold() + { + return mSquelchHysteresisOpenThreshold; + } + + /** + * Sets the squelch time threshold in the range 1-10. + * @param open threshold + */ + public void setSquelchHysteresisOpenThreshold(int open) + { + if(open < NoiseSquelch.MINIMUM_HYSTERESIS_THRESHOLD || open > NoiseSquelch.MAXIMUM_HYSTERESIS_THRESHOLD) + { + throw new IllegalArgumentException("Squelch hysteresis open threshold is out of range: " + open); + } + + mSquelchHysteresisOpenThreshold = open; + } + + /** + * Squelch hysteresis close threshold in range 1-10 with a default of 4. + * @return hysteresis close threshold + */ + @JacksonXmlProperty(isAttribute = true, localName = "squelchHysteresisCloseThreshold") + public int getSquelchHysteresisCloseThreshold() + { + return mSquelchHysteresisCloseThreshold; + } + + /** + * Sets the squelch close threshold in the range 1-10. + * @param close threshold + */ + public void setSquelchHysteresisCloseThreshold(int close) + { + if(close < NoiseSquelch.MINIMUM_HYSTERESIS_THRESHOLD || close > NoiseSquelch.MAXIMUM_HYSTERESIS_THRESHOLD) + { + throw new IllegalArgumentException("Squelch hysteresis close threshold is out of range: " + close); + } + + mSquelchHysteresisCloseThreshold = close; + } } diff --git a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java index bde9bb1b..058baac9 100644 --- a/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java +++ b/src/main/java/io/github/dsheirer/module/decode/nbfm/NBFMDecoder.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -18,34 +18,387 @@ */ package io.github.dsheirer.module.decode.nbfm; -import io.github.dsheirer.dsp.fm.SquelchingFMDemodulator; +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.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; +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.analog.SquelchingAnalogDecoder; +import io.github.dsheirer.module.decode.PrimaryDecoder; +import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.sample.complex.ComplexSamples; +import io.github.dsheirer.sample.complex.IComplexSamplesListener; +import io.github.dsheirer.sample.real.IRealBufferProvider; +import io.github.dsheirer.source.ISourceEventListener; +import io.github.dsheirer.source.SourceEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Narrow Band FM Decoder module with integrated squelch control. + * NBFM decoder with integrated noise squelch. Demodulates complex sample buffers and feeds unfiltered, demodulated + * audio to Noise Squelch. Squelch operates on the noise level with open and close thresholds to pass low-noise audio + * and block high-noise audio. Audio is filtered and resampled to 8 kHz for downstream consumers. */ -public class NBFMDecoder extends SquelchingAnalogDecoder +public class NBFMDecoder extends PrimaryDecoder implements ISourceEventListener, IComplexSamplesListener, + Listener, IRealBufferProvider, IDecoderStateEventProvider, INoiseSquelchController { private final static Logger mLog = LoggerFactory.getLogger(NBFMDecoder.class); - private static final float SQUELCH_ALPHA_DECAY = 0.0004f; + private static final double DEMODULATED_AUDIO_SAMPLE_RATE = 8000.0; + private final IDemodulator mDemodulator = FmDemodulatorFactory.getFmDemodulator(); + private final SourceEventProcessor mSourceEventProcessor = new SourceEventProcessor(); + private final NoiseSquelch mNoiseSquelch; + private IRealFilter mIBasebandFilter; + private IRealFilter mQBasebandFilter; + private IRealDecimationFilter mIDecimationFilter; + private IRealDecimationFilter mQDecimationFilter; + private Listener mResampledBufferListener; + private Listener mDecoderStateEventListener; + private RealResampler mResampler; + private final double mChannelBandwidth; /** * Constructs an instance * - * @param config to setup the decoder + * @param config to setup the NBFM decoder and noise squelch control. */ public NBFMDecoder(DecodeConfigNBFM config) { - super(config, new SquelchingFMDemodulator(SQUELCH_ALPHA_DECAY, config.getSquelchThreshold(), - config.isSquelchAutoTrack())); + super(config); + + //Save channel bandwidth to setup channel baseband filter. + mChannelBandwidth = config.getBandwidth().getValue(); + mNoiseSquelch = new NoiseSquelch(config.getSquelchNoiseOpenThreshold(), config.getSquelchNoiseCloseThreshold(), + config.getSquelchHysteresisOpenThreshold(), config.getSquelchHysteresisCloseThreshold()); + + //Send squelch controlled audio to the resampler and notify the decoder state that the call continues. + mNoiseSquelch.setAudioListener(audio -> { + mResampler.resample(audio); + notifyCallContinuation(); + }); + + //Notify the decoder state of call starts and ends + mNoiseSquelch.setSquelchStateListener(squelchState -> { + if(squelchState == SquelchState.SQUELCH) + { + notifyCallEnd(); + } + else + { + notifyCallStart(); + } + }); } + /** + * Decoder type. + * @return type + */ @Override public DecoderType getDecoderType() { return DecoderType.NBFM; } + + /** + * Decode configuration for this decoder. + * @return configuration + */ + @Override + public DecodeConfigNBFM getDecodeConfiguration() + { + return (DecodeConfigNBFM)super.getDecodeConfiguration(); + } + + /** + * Register the noise squelch state listener. This will normally be a GUI noise squelch state view/controller. + * @param listener to receive states or pass null to de-register a listener. + */ + @Override + public void setNoiseSquelchStateListener(Listener listener) + { + mNoiseSquelch.setNoiseSquelchStateListener(listener); + } + + /** + * Applies new open and close noise threshold values for the noise squelch. + * @param open for the open noise variance calculation in range 0.1 - 0.5 where open <= close value. + * @param close for the close noise variance calculation. in range 0.1 - 0.5 where close >= open. + */ + @Override + public void setNoiseThreshold(float open, float close) + { + mNoiseSquelch.setNoiseThreshold(open, close); + + //Update the channel configuration and schedule a playlist save. + getDecodeConfiguration().setSquelchNoiseOpenThreshold(open); + getDecodeConfiguration().setSquelchNoiseCloseThreshold(close); + } + + /** + * Sets the open and close hysteresis thresholds in units of 10 milliseconds. + * @param open in range 1-10, recommend: 4 where open <= close + * @param close in range 1-10, recommend: 6 where close >= open. + */ + @Override + public void setHysteresisThreshold(int open, int close) + { + mNoiseSquelch.setHysteresisThreshold(open, close); + getDecodeConfiguration().setSquelchHysteresisOpenThreshold(open); + getDecodeConfiguration().setSquelchHysteresisCloseThreshold(close); + } + + /** + * Sets the squelch override state to temporarily bypass/override squelch control and pass all audio. + * @param override (true) or (false) to turn off squelch override. + */ + @Override + public void setSquelchOverride(boolean override) + { + mNoiseSquelch.setSquelchOverride(override); + } + + /** + * Implements the ISourceEventListener interface + */ + @Override + public Listener getSourceEventListener() + { + return mSourceEventProcessor; + } + + /** + * Module interface methods - unused. + */ + @Override + public void reset() {} + + @Override + public void start() {} + + @Override + public void stop() {} + + /** + * Broadcasts the demodulated, resampled to 8 kHz audio samples to the registered listener. + * + * @param demodulatedSamples to broadcast + */ + protected void broadcast(float[] demodulatedSamples) + { + if(mResampledBufferListener != null) + { + mResampledBufferListener.receive(demodulatedSamples); + } + } + + /** + * Implements the IRealBufferProvider interface to register a listener for demodulated audio samples. + * + * @param listener to receive demodulated, resampled audio sample buffers. + */ + @Override + public void setBufferListener(Listener listener) + { + mResampledBufferListener = listener; + } + + /** + * Implements the IRealBufferProvider interface to deregister a listener from receiving demodulated audio samples. + */ + @Override + public void removeBufferListener() + { + mResampledBufferListener = null; + } + + /** + * Implements the IComplexSampleListener interface to receive a stream of complex sample buffers. + */ + @Override + public Listener getComplexSamplesListener() + { + return this; + } + + /** + * Implements the Listener interface to receive a stream of complex I/Q sample buffers + */ + @Override + public void receive(ComplexSamples samples) + { + if(mIDecimationFilter == null || mQDecimationFilter == null) + { + throw new IllegalStateException("NBFM demodulator module must receive a sample rate change source event " + + "before it can process complex sample buffers"); + } + + float[] decimatedI = mIDecimationFilter.decimateReal(samples.i()); + float[] decimatedQ = mQDecimationFilter.decimateReal(samples.q()); + + float[] filteredI = mIBasebandFilter.filter(decimatedI); + float[] filteredQ = mQBasebandFilter.filter(decimatedQ); + + float[] demodulated = mDemodulator.demodulate(filteredI, filteredQ); + + mNoiseSquelch.process(demodulated); + + //Once we process the sample buffer, if the ending state is squelch closed, update the decoder state that we + // are idle. + if(mNoiseSquelch.isSquelched()) + { + notifyIdle(); + } + } + + /** + * Broadcasts a call start state event + */ + private void notifyCallStart() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.START, State.CALL, 0)); + } + + /** + * Broadcasts a call continuation state event + */ + private void notifyCallContinuation() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.CALL, 0)); + } + + /** + * Broadcasts a call end state event + */ + private void notifyCallEnd() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.END, State.CALL, 0)); + } + + /** + * Broadcasts an idle notification + */ + private void notifyIdle() + { + broadcast(new DecoderStateEvent(this, DecoderStateEvent.Event.CONTINUATION, State.IDLE, 0)); + } + + /** + * Broadcasts the decoder state event to an optional registered listener + */ + private void broadcast(DecoderStateEvent event) + { + if(mDecoderStateEventListener != null) + { + mDecoderStateEventListener.receive(event); + } + } + + /** + * Sets the decoder state listener + */ + @Override + public void setDecoderStateListener(Listener listener) + { + mDecoderStateEventListener = listener; + } + + /** + * Removes the decoder state event listener + */ + @Override + public void removeDecoderStateListener() + { + mDecoderStateEventListener = null; + } + + /** + * Updates the decoder to process complex sample buffers at the specified sample rate. + * @param sampleRate of the incoming complex sample buffer stream. + */ + private void setSampleRate(double sampleRate) + { + int decimationRate = 0; + double decimatedSampleRate = sampleRate; + + if(sampleRate / 2 >= (mChannelBandwidth * 2)) + { + decimationRate = 2; + + while(sampleRate / decimationRate / 2 >= (mChannelBandwidth * 2)) + { + decimationRate *= 2; + } + } + + if(decimationRate > 0) + { + decimatedSampleRate /= decimationRate; + } + + mIDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); + mQDecimationFilter = DecimationFilterFactory.getRealDecimationFilter(decimationRate); + + if((decimatedSampleRate < (2.0 * 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")); + } + + 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); + } + + mIBasebandFilter = FilterFactory.getRealFilter(coefficients); + mQBasebandFilter = FilterFactory.getRealFilter(coefficients); + + mResampler = new RealResampler(decimatedSampleRate, DEMODULATED_AUDIO_SAMPLE_RATE, 4192, 512); + mResampler.setListener(NBFMDecoder.this::broadcast); + } + + /** + * Monitors sample rate change source event(s) to set up the filters, decimation, and demodulator. + */ + public class SourceEventProcessor implements Listener + { + @Override + public void receive(SourceEvent sourceEvent) + { + if(sourceEvent.getEvent() == SourceEvent.Event.NOTIFICATION_SAMPLE_RATE_CHANGE) + { + setSampleRate(sourceEvent.getValue().doubleValue()); + } + } + } } diff --git a/src/main/java/io/github/dsheirer/source/mixer/MixerManager.java b/src/main/java/io/github/dsheirer/source/mixer/MixerManager.java index c9102dd9..c2d8d977 100644 --- a/src/main/java/io/github/dsheirer/source/mixer/MixerManager.java +++ b/src/main/java/io/github/dsheirer/source/mixer/MixerManager.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2022 Dennis Sheirer + * Copyright (C) 2014-2025 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 @@ -24,6 +24,9 @@ import io.github.dsheirer.sample.adapter.RealShortAdapter; import io.github.dsheirer.source.config.SourceConfigMixer; import io.github.dsheirer.source.config.SourceConfiguration; import io.github.dsheirer.source.tuner.MixerTunerType; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,9 +39,6 @@ import javax.sound.sampled.Mixer; import javax.sound.sampled.Port; import javax.sound.sampled.SourceDataLine; import javax.sound.sampled.TargetDataLine; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; /** * Utility class for accessing input, output and tuner mixers @@ -483,35 +483,13 @@ public class MixerManager public static void main(String[] args) { - MixerTunerType mixerTunerType = MixerTunerType.FUNCUBE_DONGLE_PRO_PLUS; - TargetDataLine targetDataLine = getTunerTargetDataLine(mixerTunerType); + List configs = MixerManager.getOutputMixers(); - if(targetDataLine != null) + for(MixerChannelConfiguration config : configs) { - try - { - targetDataLine.open(mixerTunerType.getAudioFormat()); - mLog.info(targetDataLine.getFormat().toString()); - targetDataLine.start(); - byte[] bytes = new byte[1024]; - int read = targetDataLine.read(bytes, 0, bytes.length); - - while(read > 0) - { - mLog.debug("Read:" + read); - read = 0; -// read = targetDataLine.read(bytes, 0, bytes.length); - } - - targetDataLine.close(); - - mLog.debug("Done!"); - } - catch(Exception e) - { - e.printStackTrace(); - } + System.out.println(config); } + System.out.println(MixerManager.getMixerDevices()); } }